Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f975b84
fixing mage base attack. fixing chasing on attack action. making imps…
dwoodruffsf Mar 9, 2021
74dca7a
initial changes for action anticipation
dwoodruffsf Mar 10, 2021
40d141e
more temp work on anticipation
dwoodruffsf Mar 10, 2021
5b5bdf6
clearing bogus change to dungeontest
dwoodruffsf Mar 10, 2021
0392f82
initial pass at anticipative animations and other responsiveness tweaks
dwoodruffsf Mar 12, 2021
ace9a4f
taking develop merge
dwoodruffsf Mar 15, 2021
7d8219c
fixing compilation isue
dwoodruffsf Mar 15, 2021
ee13f39
iterating on controller and restoring click feedback
dwoodruffsf Mar 15, 2021
bfad632
more iteration on animations
dwoodruffsf Mar 15, 2021
ad2d31d
more iteration on aticipative actions
dwoodruffsf Mar 16, 2021
39adfaa
heavy comment about anticipative actions, and cleaned up issue with h…
dwoodruffsf Mar 16, 2021
151eb1c
Merge remote-tracking branch 'origin/develop' into feature/animation_…
dwoodruffsf Mar 16, 2021
2680287
removing some debug logs
dwoodruffsf Mar 16, 2021
99a1a6d
updating back to 2020.3
dwoodruffsf Mar 16, 2021
323cd8d
updating click feedback
dwoodruffsf Mar 17, 2021
9531441
taking develop merge
dwoodruffsf Mar 17, 2021
6f6967a
Merge remote-tracking branch 'origin/develop' into feature/animation_…
dwoodruffsf Mar 22, 2021
6d2b2a2
fixing up change so that you can't anticipatively animate when fainted.
dwoodruffsf Mar 22, 2021
a12343a
taking develop merge
dwoodruffsf Apr 9, 2021
bf6e230
Update ClientInputSender.cs
dwoodruffsf Apr 9, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Assets/BossRoom/GameData/Action/Imp/ImpBaseAttack.asset
Git LFS file not shown
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@ AnimatorOverrideController:
m_OverrideClip: {fileID: -6239216113759591374, guid: 2115c4661f55eff45a5a0f91fc0a12f0, type: 3}
- m_OriginalClip: {fileID: -5612658629409835226, guid: 2115c4661f55eff45a5a0f91fc0a12f0, type: 3}
m_OverrideClip: {fileID: -5612658629409835226, guid: 2115c4661f55eff45a5a0f91fc0a12f0, type: 3}
- m_OriginalClip: {fileID: -4428684883894617094, guid: 2115c4661f55eff45a5a0f91fc0a12f0, type: 3}
m_OverrideClip: {fileID: -3419257869308726280, guid: 2115c4661f55eff45a5a0f91fc0a12f0, type: 3}
65 changes: 63 additions & 2 deletions Assets/BossRoom/Scripts/Client/Game/Action/ActionFX.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ public abstract class ActionFX : ActionBase
/// </summary>
public const string k_DefaultHitReact = "HitReact1";

/// <summary>
/// True if this actionFX began running immediately, prior to getting a confirmation from the server.
/// </summary>
public bool Anticipated { get; protected set; }

public ActionFX(ref ActionRequestData data, ClientCharacterVisualization parent) : base(ref data)
{
m_Parent = parent;
Expand All @@ -21,8 +26,16 @@ public ActionFX(ref ActionRequestData data, ClientCharacterVisualization parent)
/// <summary>
/// Starts the ActionFX. Derived classes may return false if they wish to end immediately without their Update being called.
/// </summary>
/// <remarks>
/// Derived class should be sure to call base.Start() in their implementation, but note that this resets "Anticipated" to false.
/// </remarks>
/// <returns>true to play, false to be immediately cleaned up.</returns>
public abstract bool Start();
public virtual bool Start()
{
Anticipated = false; //once you start for real you are no longer an anticipated action.
TimeStarted = UnityEngine.Time.time;
return true;
}

public abstract bool Update();

Expand Down Expand Up @@ -54,17 +67,65 @@ public static ActionFX MakeActionFX(ref ActionRequestData data, ClientCharacterV
case ActionLogic.RangedFXTargeted: return new FXProjectileTargetedActionFX(ref data, parent);
case ActionLogic.Trample: return new TrampleActionFX(ref data, parent);
case ActionLogic.AoE: return new AoeActionFX(ref data, parent);
case ActionLogic.Stunned: return new AnimationOnlyActionFX(ref data, parent);
case ActionLogic.Target: return new TargetActionFX(ref data, parent);

case ActionLogic.ChargedShield:
case ActionLogic.ChargedLaunchProjectile: return new ChargedActionFX(ref data, parent);

case ActionLogic.StealthMode: return new StealthModeActionFX(ref data, parent);

case ActionLogic.Stunned:
case ActionLogic.LaunchProjectile:
case ActionLogic.Revive:
case ActionLogic.Emote: return new AnimationOnlyActionFX(ref data, parent);

default: throw new System.NotImplementedException();
}
}

/// <summary>
/// Should this ActionFX be created anticipatively on the owning client?
/// </summary>
/// <param name="parent">The ActionVisualization that would be playing this ActionFX.</param>
/// <param name="data">The request being sent to the server</param>
/// <returns>If true ActionVisualization should pre-emptively create the ActionFX on the owning client, before hearing back from the server.</returns>
public static bool ShouldAnticipate(ActionVisualization parent, ref ActionRequestData data)
{
if( !parent.Parent.CanPerformActions ) { return false; }

var actionDescription = GameDataSource.Instance.ActionDataByType[data.ActionTypeEnum];

//for actions with ShouldClose set, we check our range locally. If we are out of range, we shouldn't anticipate, as we will
//need to execute a ChaseAction (synthesized on the server) prior to actually playing the skill.
bool isTargetEligible = true;
if( data.ShouldClose == true )
{
ulong targetId = (data.TargetIds != null && data.TargetIds.Length > 0) ? data.TargetIds[0] : 0;
if( MLAPI.Spawning.NetworkSpawnManager.SpawnedObjects.TryGetValue(targetId, out MLAPI.NetworkObject networkObject ) )
{
float rangeSquared = actionDescription.Range * actionDescription.Range;
isTargetEligible = (networkObject.transform.position - parent.Parent.transform.position).sqrMagnitude < rangeSquared;
}
}

//at present all Actionts anticipate except for the Target action, which runs a single instance on the client and is
//responsible for action anticipation on its own.
return isTargetEligible && actionDescription.Logic != ActionLogic.Target;
}

public virtual void OnAnimEvent(string id) { }
public virtual void OnStoppedChargingUp() { }

/// <summary>
/// Called when the action is being "anticipated" on the client. For example, if you are the owner of a tank and you swing your hammer,
/// you get this call immediately on the client, before the server round-trip.
/// Overriders should always call the base class in their implementation!
/// </summary>
public virtual void AnticipateAction()
{
Anticipated = true;
TimeStarted = UnityEngine.Time.time;
}
}

}
Expand Down
85 changes: 66 additions & 19 deletions Assets/BossRoom/Scripts/Client/Game/Action/ActionVisualization.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,19 @@ namespace BossRoom.Visual
/// </summary>
public class ActionVisualization
{
private List<ActionFX> m_PlayingActions;
private List<ActionFX> m_PlayingActions = new List<ActionFX>();

/// <summary>
/// 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.
/// </summary>
private const float k_AnticipationTimeoutSeconds = 1;

public ClientCharacterVisualization Parent { get; private set; }

public ActionVisualization(ClientCharacterVisualization parent)
{
Parent = parent;
m_PlayingActions = new List<ActionFX>();
}

public void Update()
Expand All @@ -27,15 +32,24 @@ public void Update()
var action = m_PlayingActions[i];
bool keepGoing = action.Update();
bool expirable = action.Description.DurationSeconds > 0f; //non-positive value is a sentinel indicating the duration is indefinite.
bool timeExpired = expirable && (Time.time - action.TimeStarted) >= action.Description.DurationSeconds;
if (!keepGoing || timeExpired)
bool timeExpired = expirable && action.TimeRunning >= action.Description.DurationSeconds;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

action.TimeRunning does not directly imply that we're measuring seconds - I would adjust the name of this variable to reflect that it's indeed in seconds.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with that, but it's a large change (in number of files touched), and not directly related to this change--TimeRunning and TimeStarted have been around a while. I would like to come back and do that in a follow-on. I made a separate branch (dmw_rename_timerunning), containing a stab at it. These big refactors make my VS Studio throw exceptions 😢

bool timedOut = action.Anticipated && action.TimeRunning >= k_AnticipationTimeoutSeconds;
if (!keepGoing || timeExpired || timedOut)
{
action.End();
if (timedOut) { action.Cancel(); } //an anticipated action that timed out shouldn't get its End called. It is canceled instead.
else { action.End(); }

m_PlayingActions.RemoveAt(i);
}
}
}

//helper wrapper for a FindIndex call on m_PlayingActions.
private int FindAction(ActionType action, bool anticipatedOnly )
{
return m_PlayingActions.FindIndex(a => a.Description.ActionTypeEnum == action && (!anticipatedOnly || a.Anticipated));
}

public void OnAnimEvent(string id)
{
foreach (var actionFX in m_PlayingActions)
Expand All @@ -52,22 +66,55 @@ public void OnStoppedChargingUp()
}
}

public void PlayAction(ref ActionRequestData data)
/// <summary>
/// Called on the client that owns the Character when the player triggers an action. This allows actions to immediately start playing feedback.
/// </summary>
/// <remarks>
///
/// 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.
///
/// </remarks>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love this comment! @SamuelBellomo - seems like a good thing to reference in our docs on latency masking techniques.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks :-)

/// <param name="data">The Action that is being requested.</param>
public void AnticipateAction(ref ActionRequestData data)
{
ActionDescription actionDesc = GameDataSource.Instance.ActionDataByType[data.ActionTypeEnum];

//Do Trivial Actions (actions that just require playing a single animation, and don't require any state tracking).
switch (actionDesc.Logic)
if (!Parent.IsAnimating && ActionFX.ShouldAnticipate(this, ref data))
{
case ActionLogic.LaunchProjectile:
case ActionLogic.Revive:
case ActionLogic.Emote:
Parent.OurAnimator.SetTrigger(actionDesc.Anim);
return;
var actionFX = ActionFX.MakeActionFX(ref data, Parent);
actionFX.AnticipateAction();
m_PlayingActions.Add(actionFX);
}
}

var actionFX = ActionFX.MakeActionFX(ref data, Parent);
actionFX.TimeStarted = Time.time;
public void PlayAction(ref ActionRequestData data)
{
var anticipatedActionIndex = FindAction(data.ActionTypeEnum, true);

var actionFX = anticipatedActionIndex>=0 ? m_PlayingActions[anticipatedActionIndex] : ActionFX.MakeActionFX(ref data, Parent);
if (actionFX.Start())
{
m_PlayingActions.Add(actionFX);
Expand All @@ -85,7 +132,7 @@ public void CancelAllActions()

public void CancelAllActionsOfType(ActionType actionType)
{
for (int i = m_PlayingActions.Count-1; i >=0; --i)
for (int i = m_PlayingActions.Count - 1; i >= 0; --i)
{
if (m_PlayingActions[i].Description.ActionTypeEnum == actionType)
{
Expand All @@ -100,7 +147,7 @@ public void CancelAllActionsOfType(ActionType actionType)
/// </summary>
public void CancelAll()
{
foreach( var action in m_PlayingActions )
foreach (var action in m_PlayingActions)
{
action.Cancel();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,32 @@ public AnimationOnlyActionFX(ref ActionRequestData data, ClientCharacterVisualiz

public override bool Start()
{
m_Parent.OurAnimator.SetTrigger(Description.Anim);
if( !Anticipated )
{
PlayStartAnim();
}

base.Start();
return true;
}

private void PlayStartAnim()
{
m_Parent.OurAnimator.SetTrigger(Description.Anim);
}

public override void AnticipateAction()
{
base.AnticipateAction();
PlayStartAnim();
}

public override bool Update()
{
return ActionConclusion.Continue;
}

public override void End()
public override void Cancel()
{
if (!string.IsNullOrEmpty(Description.Anim2))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public AoeActionFX(ref ActionRequestData data, ClientCharacterVisualization pare

public override bool Start()
{
base.Start();
m_Parent.OurAnimator.SetTrigger(Description.Anim);
GameObject.Instantiate(Description.Spawns[0], m_Data.Position, Quaternion.identity);
return ActionConclusion.Stop;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public ChargedActionFX(ref ActionRequestData data, ClientCharacterVisualization

public override bool Start()
{
base.Start();
m_Parent.OurAnimator.SetTrigger(Description.Anim);

if (Description.Spawns.Length > 0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public FXProjectileTargetedActionFX(ref ActionRequestData data, ClientCharacterV

public override bool Start()
{
bool wasAnticipated = Anticipated;
base.Start();
m_Target = GetTarget();
if (HasTarget() && m_Target == null)
{
Expand All @@ -44,10 +46,19 @@ public override bool Start()
m_Projectile = SpawnAndInitializeProjectile();

// animate shooting the projectile
m_Parent.OurAnimator.SetTrigger(Description.Anim);
if( !wasAnticipated )
{
PlayFireAnim();
}

return true;
}

private void PlayFireAnim()
{
m_Parent.OurAnimator.SetTrigger(Description.Anim);
}

public override bool Update()
{
// we keep going until the projectile's duration ends
Expand Down Expand Up @@ -128,5 +139,11 @@ private FXProjectile SpawnAndInitializeProjectile()
projectile.Initialize(m_Parent.transform.position, m_Target?.transform, m_Data.Position, Description.ExecTimeSeconds, m_ProjectileDuration);
return projectile;
}

public override void AnticipateAction()
{
base.AnticipateAction();
PlayFireAnim();
}
}
}
21 changes: 20 additions & 1 deletion Assets/BossRoom/Scripts/Client/Game/Action/MeleeActionFX.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ public MeleeActionFX(ref ActionRequestData data, ClientCharacterVisualization pa

public override bool Start()
{
m_Parent.OurAnimator.SetTrigger(Description.Anim);
if( !Anticipated)
{
PlayAnim();
}

base.Start();
return true;
}

Expand All @@ -45,6 +50,11 @@ public override void End()
PlayHitReact();
}

private void PlayAnim()
{
m_Parent.OurAnimator.SetTrigger(Description.Anim);
}

private void PlayHitReact()
{
if (m_ImpactPlayed) { return; }
Expand Down Expand Up @@ -74,5 +84,14 @@ private void PlayHitReact()
//in the future we may do another physics check to handle the case where a target "ran under our weapon".
//But for now, if the original target is no longer present, then we just don't play our hit react on anything.
}

public override void AnticipateAction()
{
base.AnticipateAction();

//note: because the hit-react is driven from the animation, this means we can anticipatively trigger a hit-react too. That
//will make combat feel responsive, but of course the actual damage won't be applied until the server tells us about it.
PlayAnim();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public StealthModeActionFX(ref ActionRequestData data, ClientCharacterVisualizat

public override bool Start()
{
base.Start();
m_Parent.OurAnimator.SetTrigger(Description.Anim);
return true;
}
Expand Down
Loading