using System.Collections.Generic; using Unity.BossRoom.Gameplay.GameplayObjects.Character; namespace Unity.BossRoom.Gameplay.Actions { /// /// This is a companion class to ClientCharacter that is specifically responsible for visualizing Actions. Action visualizations have lifetimes /// and ongoing state, making this class closely analogous in spirit to the Unity.Multiplayer.Samples.BossRoom.Actions.ServerActionPlayer class. /// public sealed class ClientActionPlayer { private List m_PlayingActions = new List(); /// /// Don't let anticipated actionFXs persist longer than this. This is a safeguard against scenarios /// where we never get a confirmed action for an action we anticipated. /// private const float k_AnticipationTimeoutSeconds = 1; public ClientCharacter ClientCharacter { get; private set; } public ClientActionPlayer(ClientCharacter clientCharacter) { ClientCharacter = clientCharacter; } public void OnUpdate() { //do a reverse-walk so we can safely remove inside the loop. for (int i = m_PlayingActions.Count - 1; i >= 0; --i) { var action = m_PlayingActions[i]; bool keepGoing = action.AnticipatedClient || action.OnUpdateClient(ClientCharacter); // only call OnUpdate() on actions that are past anticipation bool expirable = action.Config.DurationSeconds > 0f; //non-positive value is a sentinel indicating the duration is indefinite. bool timeExpired = expirable && action.TimeRunning >= action.Config.DurationSeconds; bool timedOut = action.AnticipatedClient && action.TimeRunning >= k_AnticipationTimeoutSeconds; if (!keepGoing || timeExpired || timedOut) { if (timedOut) { action.CancelClient(ClientCharacter); } //an anticipated action that timed out shouldn't get its End called. It is canceled instead. else { action.EndClient(ClientCharacter); } m_PlayingActions.RemoveAt(i); ActionFactory.ReturnAction(action); } } } //helper wrapper for a FindIndex call on m_PlayingActions. private int FindAction(ActionID actionID, bool anticipatedOnly) { return m_PlayingActions.FindIndex(a => a.ActionID == actionID && (!anticipatedOnly || a.AnticipatedClient)); } public void OnAnimEvent(string id) { foreach (var actionFX in m_PlayingActions) { actionFX.OnAnimEventClient(ClientCharacter, id); } } public void OnStoppedChargingUp(float finalChargeUpPercentage) { foreach (var actionFX in m_PlayingActions) { actionFX.OnStoppedChargingUpClient(ClientCharacter, finalChargeUpPercentage); } } /// /// Called on the client that owns the Character when the player triggers an action. This allows actions to immediately start playing feedback. /// /// /// /// What is Action Anticipation and what problem does it solve? In short, it lets Actions run logic the moment the input event that triggers them /// is detected on the local client. The purpose of this is to help mask latency. Because this demo is server authoritative, the default behavior is /// to only see feedback for your input after a server-client roundtrip. Somewhere over 200ms of round-trip latency, this starts to feel oppressively sluggish. /// To combat this, you can play visual effects immediately. For example, MeleeActionFX plays both its weapon swing and applies a hit react to the target, /// without waiting to hear from the server. This can lead to discrepancies when the server doesn't think the target was hit, but on the net, will feel /// more responsive. /// /// An important concept of Action Anticipation is that it is opportunistic--it doesn't make any strong guarantees. You don't get an anticipated /// action animation if you are already animating in some way, as one example. Another complexity is that you don't know if the server will actually /// let you play all the actions that you've requested--some may get thrown away, e.g. because you have too many actions in your queue. What this means /// is that Anticipated Actions (actions that have been constructed but not started) won't match up perfectly with actual approved delivered actions from /// the server. For that reason, it must always be fine to receive PlayAction and not have an anticipated action already started (this is true for playback /// Characters belonging to the server and other characters anyway). It also means we need to handle the case where we created an Anticipated Action, but /// never got a confirmation--actions like that need to eventually get discarded. /// /// Another important aspect of Anticipated Actions is that they are an "opt-in" system. You must call base.Start in your Start implementation, but other than /// that, if you don't have a good way to implement an Anticipation for your action, you don't have to do anything. In this case, that action will play /// "normally" (with visual feedback starting when the server's action broadcast reaches the client). Every action type will have its own particular set of /// problems to solve to sell the anticipation effect. For example, in this demo, the mage base attack (FXProjectileTargetedActionFX) just plays the attack animation /// anticipatively, but it could be revised to create and drive the mage bolt effect as well--leaving only damage to arrive in true server time. /// /// How to implement your own Anticipation logic: /// 1. Isolate the visual feedback you want play anticipatively in a private helper method on your ActionFX, like "PlayAttackAnim". /// 2. Override ActionFX.AnticipateAction. Be sure to call base.AnticipateAction, as well as play your visual logic (like PlayAttackAnim). /// 3. In your Start method, be sure to call base.Start (note that this will reset the "Anticipated" field to false). /// 4. In Start, check if the action was Anticipated. If NOT, then play call your PlayAttackAnim method. /// /// /// The Action that is being requested. public void AnticipateAction(ref ActionRequestData data) { if (!ClientCharacter.IsAnimating() && Action.ShouldClientAnticipate(ClientCharacter, ref data)) { var actionFX = ActionFactory.CreateActionFromData(ref data); actionFX.AnticipateActionClient(ClientCharacter); m_PlayingActions.Add(actionFX); } } public void PlayAction(ref ActionRequestData data) { var anticipatedActionIndex = FindAction(data.ActionID, true); var actionFX = anticipatedActionIndex >= 0 ? m_PlayingActions[anticipatedActionIndex] : ActionFactory.CreateActionFromData(ref data); if (actionFX.OnStartClient(ClientCharacter)) { if (anticipatedActionIndex < 0) { m_PlayingActions.Add(actionFX); } //otherwise just let the action sit in it's existing slot } else if (anticipatedActionIndex >= 0) { var removedAction = m_PlayingActions[anticipatedActionIndex]; m_PlayingActions.RemoveAt(anticipatedActionIndex); ActionFactory.ReturnAction(removedAction); } } /// /// Cancels all playing ActionFX. /// public void CancelAllActions() { foreach (var action in m_PlayingActions) { action.CancelClient(ClientCharacter); ActionFactory.ReturnAction(action); } m_PlayingActions.Clear(); } public void CancelAllActionsWithSamePrototypeID(ActionID actionID) { for (int i = m_PlayingActions.Count - 1; i >= 0; --i) { if (m_PlayingActions[i].ActionID == actionID) { var action = m_PlayingActions[i]; action.CancelClient(ClientCharacter); m_PlayingActions.RemoveAt(i); ActionFactory.ReturnAction(action); } } } } }