Skip to content

Commit 993a105

Browse files
authored
Feature/animation anticipation (#175)
1 parent 2260b82 commit 993a105

15 files changed

+251
-28
lines changed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
version https://git-lfs.github.com/spec/v1
2-
oid sha256:5ba7dbcb8c32a01bdacdf8a5591b7a17bf0d68547e61b0376533608123a31b26
2+
oid sha256:f1181a174a045df822dd160e56a2603edbd3148f9f8f29c202e37aa76afd11a2
33
size 864

Assets/BossRoom/Models/CharacterSetController_Tank.overrideController

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,5 @@ AnimatorOverrideController:
2121
m_OverrideClip: {fileID: -6239216113759591374, guid: 2115c4661f55eff45a5a0f91fc0a12f0, type: 3}
2222
- m_OriginalClip: {fileID: -5612658629409835226, guid: 2115c4661f55eff45a5a0f91fc0a12f0, type: 3}
2323
m_OverrideClip: {fileID: -5612658629409835226, guid: 2115c4661f55eff45a5a0f91fc0a12f0, type: 3}
24+
- m_OriginalClip: {fileID: -4428684883894617094, guid: 2115c4661f55eff45a5a0f91fc0a12f0, type: 3}
25+
m_OverrideClip: {fileID: -3419257869308726280, guid: 2115c4661f55eff45a5a0f91fc0a12f0, type: 3}

Assets/BossRoom/Scripts/Client/Game/Action/ActionFX.cs

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ public abstract class ActionFX : ActionBase
1313
/// </summary>
1414
public const string k_DefaultHitReact = "HitReact1";
1515

16+
/// <summary>
17+
/// True if this actionFX began running immediately, prior to getting a confirmation from the server.
18+
/// </summary>
19+
public bool Anticipated { get; protected set; }
20+
1621
public ActionFX(ref ActionRequestData data, ClientCharacterVisualization parent) : base(ref data)
1722
{
1823
m_Parent = parent;
@@ -21,8 +26,16 @@ public ActionFX(ref ActionRequestData data, ClientCharacterVisualization parent)
2126
/// <summary>
2227
/// Starts the ActionFX. Derived classes may return false if they wish to end immediately without their Update being called.
2328
/// </summary>
29+
/// <remarks>
30+
/// Derived class should be sure to call base.Start() in their implementation, but note that this resets "Anticipated" to false.
31+
/// </remarks>
2432
/// <returns>true to play, false to be immediately cleaned up.</returns>
25-
public abstract bool Start();
33+
public virtual bool Start()
34+
{
35+
Anticipated = false; //once you start for real you are no longer an anticipated action.
36+
TimeStarted = UnityEngine.Time.time;
37+
return true;
38+
}
2639

2740
public abstract bool Update();
2841

@@ -54,17 +67,65 @@ public static ActionFX MakeActionFX(ref ActionRequestData data, ClientCharacterV
5467
case ActionLogic.RangedFXTargeted: return new FXProjectileTargetedActionFX(ref data, parent);
5568
case ActionLogic.Trample: return new TrampleActionFX(ref data, parent);
5669
case ActionLogic.AoE: return new AoeActionFX(ref data, parent);
57-
case ActionLogic.Stunned: return new AnimationOnlyActionFX(ref data, parent);
5870
case ActionLogic.Target: return new TargetActionFX(ref data, parent);
71+
5972
case ActionLogic.ChargedShield:
6073
case ActionLogic.ChargedLaunchProjectile: return new ChargedActionFX(ref data, parent);
74+
6175
case ActionLogic.StealthMode: return new StealthModeActionFX(ref data, parent);
76+
77+
case ActionLogic.Stunned:
78+
case ActionLogic.LaunchProjectile:
79+
case ActionLogic.Revive:
80+
case ActionLogic.Emote: return new AnimationOnlyActionFX(ref data, parent);
81+
6282
default: throw new System.NotImplementedException();
6383
}
6484
}
6585

86+
/// <summary>
87+
/// Should this ActionFX be created anticipatively on the owning client?
88+
/// </summary>
89+
/// <param name="parent">The ActionVisualization that would be playing this ActionFX.</param>
90+
/// <param name="data">The request being sent to the server</param>
91+
/// <returns>If true ActionVisualization should pre-emptively create the ActionFX on the owning client, before hearing back from the server.</returns>
92+
public static bool ShouldAnticipate(ActionVisualization parent, ref ActionRequestData data)
93+
{
94+
if( !parent.Parent.CanPerformActions ) { return false; }
95+
96+
var actionDescription = GameDataSource.Instance.ActionDataByType[data.ActionTypeEnum];
97+
98+
//for actions with ShouldClose set, we check our range locally. If we are out of range, we shouldn't anticipate, as we will
99+
//need to execute a ChaseAction (synthesized on the server) prior to actually playing the skill.
100+
bool isTargetEligible = true;
101+
if( data.ShouldClose == true )
102+
{
103+
ulong targetId = (data.TargetIds != null && data.TargetIds.Length > 0) ? data.TargetIds[0] : 0;
104+
if( MLAPI.Spawning.NetworkSpawnManager.SpawnedObjects.TryGetValue(targetId, out MLAPI.NetworkObject networkObject ) )
105+
{
106+
float rangeSquared = actionDescription.Range * actionDescription.Range;
107+
isTargetEligible = (networkObject.transform.position - parent.Parent.transform.position).sqrMagnitude < rangeSquared;
108+
}
109+
}
110+
111+
//at present all Actionts anticipate except for the Target action, which runs a single instance on the client and is
112+
//responsible for action anticipation on its own.
113+
return isTargetEligible && actionDescription.Logic != ActionLogic.Target;
114+
}
115+
66116
public virtual void OnAnimEvent(string id) { }
67117
public virtual void OnStoppedChargingUp() { }
118+
119+
/// <summary>
120+
/// 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,
121+
/// you get this call immediately on the client, before the server round-trip.
122+
/// Overriders should always call the base class in their implementation!
123+
/// </summary>
124+
public virtual void AnticipateAction()
125+
{
126+
Anticipated = true;
127+
TimeStarted = UnityEngine.Time.time;
128+
}
68129
}
69130

70131
}

Assets/BossRoom/Scripts/Client/Game/Action/ActionVisualization.cs

Lines changed: 66 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,19 @@ namespace BossRoom.Visual
99
/// </summary>
1010
public class ActionVisualization
1111
{
12-
private List<ActionFX> m_PlayingActions;
12+
private List<ActionFX> m_PlayingActions = new List<ActionFX>();
13+
14+
/// <summary>
15+
/// Don't let anticipated actionFXs persist longer than this. This is a safeguard against scenarios
16+
/// where we never get a confirmed action for an action we anticipated.
17+
/// </summary>
18+
private const float k_AnticipationTimeoutSeconds = 1;
1319

1420
public ClientCharacterVisualization Parent { get; private set; }
1521

1622
public ActionVisualization(ClientCharacterVisualization parent)
1723
{
1824
Parent = parent;
19-
m_PlayingActions = new List<ActionFX>();
2025
}
2126

2227
public void Update()
@@ -27,15 +32,24 @@ public void Update()
2732
var action = m_PlayingActions[i];
2833
bool keepGoing = action.Update();
2934
bool expirable = action.Description.DurationSeconds > 0f; //non-positive value is a sentinel indicating the duration is indefinite.
30-
bool timeExpired = expirable && (Time.time - action.TimeStarted) >= action.Description.DurationSeconds;
31-
if (!keepGoing || timeExpired)
35+
bool timeExpired = expirable && action.TimeRunning >= action.Description.DurationSeconds;
36+
bool timedOut = action.Anticipated && action.TimeRunning >= k_AnticipationTimeoutSeconds;
37+
if (!keepGoing || timeExpired || timedOut)
3238
{
33-
action.End();
39+
if (timedOut) { action.Cancel(); } //an anticipated action that timed out shouldn't get its End called. It is canceled instead.
40+
else { action.End(); }
41+
3442
m_PlayingActions.RemoveAt(i);
3543
}
3644
}
3745
}
3846

47+
//helper wrapper for a FindIndex call on m_PlayingActions.
48+
private int FindAction(ActionType action, bool anticipatedOnly )
49+
{
50+
return m_PlayingActions.FindIndex(a => a.Description.ActionTypeEnum == action && (!anticipatedOnly || a.Anticipated));
51+
}
52+
3953
public void OnAnimEvent(string id)
4054
{
4155
foreach (var actionFX in m_PlayingActions)
@@ -52,22 +66,55 @@ public void OnStoppedChargingUp()
5266
}
5367
}
5468

55-
public void PlayAction(ref ActionRequestData data)
69+
/// <summary>
70+
/// Called on the client that owns the Character when the player triggers an action. This allows actions to immediately start playing feedback.
71+
/// </summary>
72+
/// <remarks>
73+
///
74+
/// 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
75+
/// 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
76+
/// 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.
77+
/// 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,
78+
/// 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
79+
/// more responsive.
80+
///
81+
/// An important concept of Action Anticipation is that it is opportunistic--it doesn't make any strong guarantees. You don't get an anticipated
82+
/// 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
83+
/// 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
84+
/// is that Anticipated Actions (actions that have been constructed but not started) won't match up perfectly with actual approved delivered actions from
85+
/// 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
86+
/// 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
87+
/// never got a confirmation--actions like that need to eventually get discarded.
88+
///
89+
/// 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
90+
/// 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
91+
/// "normally" (with visual feedback starting when the server's action broadcast reaches the client). Every action type will have its own particular set of
92+
/// problems to solve to sell the anticipation effect. For example, in this demo, the mage base attack (FXProjectileTargetedActionFX) just plays the attack animation
93+
/// 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.
94+
///
95+
/// How to implement your own Anticipation logic:
96+
/// 1. Isolate the visual feedback you want play anticipatively in a private helper method on your ActionFX, like "PlayAttackAnim".
97+
/// 2. Override ActionFX.AnticipateAction. Be sure to call base.AnticipateAction, as well as play your visual logic (like PlayAttackAnim).
98+
/// 3. In your Start method, be sure to call base.Start (note that this will reset the "Anticipated" field to false).
99+
/// 4. In Start, check if the action was Anticipated. If NOT, then play call your PlayAttackAnim method.
100+
///
101+
/// </remarks>
102+
/// <param name="data">The Action that is being requested.</param>
103+
public void AnticipateAction(ref ActionRequestData data)
56104
{
57-
ActionDescription actionDesc = GameDataSource.Instance.ActionDataByType[data.ActionTypeEnum];
58-
59-
//Do Trivial Actions (actions that just require playing a single animation, and don't require any state tracking).
60-
switch (actionDesc.Logic)
105+
if (!Parent.IsAnimating && ActionFX.ShouldAnticipate(this, ref data))
61106
{
62-
case ActionLogic.LaunchProjectile:
63-
case ActionLogic.Revive:
64-
case ActionLogic.Emote:
65-
Parent.OurAnimator.SetTrigger(actionDesc.Anim);
66-
return;
107+
var actionFX = ActionFX.MakeActionFX(ref data, Parent);
108+
actionFX.AnticipateAction();
109+
m_PlayingActions.Add(actionFX);
67110
}
111+
}
68112

69-
var actionFX = ActionFX.MakeActionFX(ref data, Parent);
70-
actionFX.TimeStarted = Time.time;
113+
public void PlayAction(ref ActionRequestData data)
114+
{
115+
var anticipatedActionIndex = FindAction(data.ActionTypeEnum, true);
116+
117+
var actionFX = anticipatedActionIndex>=0 ? m_PlayingActions[anticipatedActionIndex] : ActionFX.MakeActionFX(ref data, Parent);
71118
if (actionFX.Start())
72119
{
73120
m_PlayingActions.Add(actionFX);
@@ -85,7 +132,7 @@ public void CancelAllActions()
85132

86133
public void CancelAllActionsOfType(ActionType actionType)
87134
{
88-
for (int i = m_PlayingActions.Count-1; i >=0; --i)
135+
for (int i = m_PlayingActions.Count - 1; i >= 0; --i)
89136
{
90137
if (m_PlayingActions[i].Description.ActionTypeEnum == actionType)
91138
{
@@ -100,7 +147,7 @@ public void CancelAllActionsOfType(ActionType actionType)
100147
/// </summary>
101148
public void CancelAll()
102149
{
103-
foreach( var action in m_PlayingActions )
150+
foreach (var action in m_PlayingActions)
104151
{
105152
action.Cancel();
106153
}

Assets/BossRoom/Scripts/Client/Game/Action/AnimationOnlyActionFX.cs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,32 @@ public AnimationOnlyActionFX(ref ActionRequestData data, ClientCharacterVisualiz
1313

1414
public override bool Start()
1515
{
16-
m_Parent.OurAnimator.SetTrigger(Description.Anim);
16+
if( !Anticipated )
17+
{
18+
PlayStartAnim();
19+
}
20+
21+
base.Start();
1722
return true;
1823
}
1924

25+
private void PlayStartAnim()
26+
{
27+
m_Parent.OurAnimator.SetTrigger(Description.Anim);
28+
}
29+
30+
public override void AnticipateAction()
31+
{
32+
base.AnticipateAction();
33+
PlayStartAnim();
34+
}
35+
2036
public override bool Update()
2137
{
2238
return ActionConclusion.Continue;
2339
}
2440

25-
public override void End()
41+
public override void Cancel()
2642
{
2743
if (!string.IsNullOrEmpty(Description.Anim2))
2844
{

Assets/BossRoom/Scripts/Client/Game/Action/AoeActionFX.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public AoeActionFX(ref ActionRequestData data, ClientCharacterVisualization pare
1111

1212
public override bool Start()
1313
{
14+
base.Start();
1415
m_Parent.OurAnimator.SetTrigger(Description.Anim);
1516
GameObject.Instantiate(Description.Spawns[0], m_Data.Position, Quaternion.identity);
1617
return ActionConclusion.Stop;

Assets/BossRoom/Scripts/Client/Game/Action/ChargedActionFX.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ public ChargedActionFX(ref ActionRequestData data, ClientCharacterVisualization
2727

2828
public override bool Start()
2929
{
30+
base.Start();
3031
m_Parent.OurAnimator.SetTrigger(Description.Anim);
3132

3233
if (Description.Spawns.Length > 0)

Assets/BossRoom/Scripts/Client/Game/Action/FXProjectileTargetedActionFX.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ public FXProjectileTargetedActionFX(ref ActionRequestData data, ClientCharacterV
2525

2626
public override bool Start()
2727
{
28+
bool wasAnticipated = Anticipated;
29+
base.Start();
2830
m_Target = GetTarget();
2931
if (HasTarget() && m_Target == null)
3032
{
@@ -44,10 +46,19 @@ public override bool Start()
4446
m_Projectile = SpawnAndInitializeProjectile();
4547

4648
// animate shooting the projectile
47-
m_Parent.OurAnimator.SetTrigger(Description.Anim);
49+
if( !wasAnticipated )
50+
{
51+
PlayFireAnim();
52+
}
53+
4854
return true;
4955
}
5056

57+
private void PlayFireAnim()
58+
{
59+
m_Parent.OurAnimator.SetTrigger(Description.Anim);
60+
}
61+
5162
public override bool Update()
5263
{
5364
// we keep going until the projectile's duration ends
@@ -128,5 +139,11 @@ private FXProjectile SpawnAndInitializeProjectile()
128139
projectile.Initialize(m_Parent.transform.position, m_Target?.transform, m_Data.Position, Description.ExecTimeSeconds, m_ProjectileDuration);
129140
return projectile;
130141
}
142+
143+
public override void AnticipateAction()
144+
{
145+
base.AnticipateAction();
146+
PlayFireAnim();
147+
}
131148
}
132149
}

Assets/BossRoom/Scripts/Client/Game/Action/MeleeActionFX.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@ public MeleeActionFX(ref ActionRequestData data, ClientCharacterVisualization pa
2121

2222
public override bool Start()
2323
{
24-
m_Parent.OurAnimator.SetTrigger(Description.Anim);
24+
if( !Anticipated)
25+
{
26+
PlayAnim();
27+
}
28+
29+
base.Start();
2530
return true;
2631
}
2732

@@ -45,6 +50,11 @@ public override void End()
4550
PlayHitReact();
4651
}
4752

53+
private void PlayAnim()
54+
{
55+
m_Parent.OurAnimator.SetTrigger(Description.Anim);
56+
}
57+
4858
private void PlayHitReact()
4959
{
5060
if (m_ImpactPlayed) { return; }
@@ -74,5 +84,14 @@ private void PlayHitReact()
7484
//in the future we may do another physics check to handle the case where a target "ran under our weapon".
7585
//But for now, if the original target is no longer present, then we just don't play our hit react on anything.
7686
}
87+
88+
public override void AnticipateAction()
89+
{
90+
base.AnticipateAction();
91+
92+
//note: because the hit-react is driven from the animation, this means we can anticipatively trigger a hit-react too. That
93+
//will make combat feel responsive, but of course the actual damage won't be applied until the server tells us about it.
94+
PlayAnim();
95+
}
7796
}
7897
}

Assets/BossRoom/Scripts/Client/Game/Action/StealthModeActionFX.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ public StealthModeActionFX(ref ActionRequestData data, ClientCharacterVisualizat
2727

2828
public override bool Start()
2929
{
30+
base.Start();
3031
m_Parent.OurAnimator.SetTrigger(Description.Anim);
3132
return true;
3233
}

0 commit comments

Comments
 (0)