This page is part of the 3D Game Kit sample.
Most combat games feature attack combos where performing multiple attacks in quick succession will use various different animations in a sequence which resets to the beginning if you stop attacking or reach the end, which is what we have here:
Mecanim
The Mecanim character's MeleeCombatSM
state is a Sub State Machine containing several Attack animations:
At first glance, the general concept is easy to understand - it will start with the first Attack animation (EllenCombo1
) and continue to each of the others in order if you keep trying to attack - but as usual, the specific implementation of that logic is scattered all over the place:
PlayerInput.Update
checks for the attack input in order to start theAttackWait
coroutine.AttackWait
setsm_CurrentAnimation = true;
then waits for 0.03 seconds, then sets it back tofalse
. This is a dirty hack to ensure that the input doesn't get missed in case there are multipleUpdate
s in a row without aFixedUpdate
between them, but otherwise it's an arbitrary amount of time which is too small to have any other use.PlayerController.FixedUpdate
resets theMeleeAttack
trigger in the Animator Controller every frame and then sets it if the above input was detected. This means it should just be a bool parameter since the features of a trigger aren't even being used.FixedUpdate
also gets the normalized time of the current state from the Animator Controller and sends it back into theStateTime
parameter.- The Animator Controller uses the
MeleeAttack
trigger to transition into theMeleeCombatSM
sub state machine and play the first attack animation (EllenCombo1
). FixedUpdate
also callsSetTargetRotation
which checksif (m_InAttack)
in order to turn the player towards a nearby enemy if there is one.- Searching for references to
m_InAttack
finds that the only place it gets set to true is theMeleeAttackStart
method which has a comment explaining that it's called by an Animation Event. FixedUpdate
also callsPlayAudio
which plays a sound if one of the attack states was just entered. This is another dirty hack because it does so by checking each of the individual state names (EllenCombo1
,EllenCombo2
, etc.).- Each of the
EllenCombo
states has aStateMachineBehaviour
calledEllenStaffEffect
with anint effectIndex
which is set in the Inspector. When a state is entered itsOnStateEnter
method gets thePlayerController
and accesses the effect of their weapon at the specified index in order to callTimeEffect.Activate
on it. - That method activates an animated weapon trail and a
Light
, then uses a coroutine to wait until the animation finishes to deactivate it. - Each of the
EllenCombo
states has various transitions, including one which leads to the next state in the sequence on the conditions thatMeleeAttack
is true (meaning the player is trying to attack) and theStateTime
(from step #4) is within a certain range.
Note how several of those steps don't directly lead into the next. PlayerInput
doesn't just call a method which you can read to in order to find out what it does, instead it sets a value and you need to check everywhere that references that value to find out what effect it will have. This is a big part of what makes the setup so convoluted.
Animancer
The Animancer character handles all the above logic in the AttackState
script which makes the flow of logic far easier to understand:
using Animancer;
using Animancer.Units;
using System;
using UnityEngine;
using UnityEngine.Events;
public class AttackState : CharacterState
{
[SerializeField, DegreesPerSecond] private float _TurnSpeed = 400;
[SerializeField] private UnityEvent _SetWeaponOwner;
[SerializeField] private UnityEvent _OnStart;
[SerializeField] private UnityEvent _OnEnd;
[SerializeField] private ClipTransition[] _Animations;
private int _CurrentAnimationIndex = int.MaxValue;
private ClipTransition _CurrentAnimation;
protected virtual void Awake()
{
_SetWeaponOwner.Invoke();
}
public override bool CanEnterState => Character.Movement.IsGrounded;
protected virtual void OnEnable()
{
if (_CurrentAnimationIndex >= _Animations.Length - 1 ||
_Animations[_CurrentAnimationIndex].State.Weight == 0)
{
_CurrentAnimationIndex = 0;
}
else
{
_CurrentAnimationIndex++;
}
_CurrentAnimation = _Animations[_CurrentAnimationIndex];
Character.Animancer.Play(_CurrentAnimation);
Character.ForwardSpeed = 0;
_OnStart.Invoke();
}
protected virtual void OnDisable()
{
_OnEnd.Invoke();
}
public override bool FullMovementControl => false;
protected virtual void FixedUpdate()
{
if (Character.CheckMotionState())
return;
Character.Movement.TurnTowards(Character.Parameters.MovementDirection, _TurnSpeed);
}
public override bool CanExitState
=> _CurrentAnimation.State.NormalizedTime >= _CurrentAnimation.State.Events.NormalizedEndTime;
}
Well, not quite all the logic:
Buffered Input
The initial input check is handled by the GameKitCharacterBrain
script.
It starts with a reference to the Attack state which is assigned in the Inspector.
// GameKitCharacterBrain.cs:
[SerializeField] private CharacterState _Attack;
Note that this is the only place where the Attack state is actually referenced. The Character
script has references to the core states - Respawn, Idle, Locomotion, and Airborne - but none of them need to know or care whether the character can attack.
In the Mecanim character, each attack has a window of time during which you could press the attack button to perform the next attack in the combo (step #11 above). These windows were defined in the transition conditions which made them tricky to work with for two main reasons:
- You can't view more than one at a time and it takes two clicks to swap between them.
- Since the
StateTime
parameter is actually set using the state's normalized time, it's hard to judge how much time a particular value actually represents (in seconds).
Instead of trying to replicate that setup, the Animancer character instead uses Animancer's Input Buffering system in exactly the same way as the Weapons sample.
Fields
Now that we have taken care of getting the character into the AttackState
, we can make some Serialized Fields to show in the Inspector:
Code | Inspector |
---|---|
In order to interact with the existing systems in the 3D Game Kit Lite (such as killing enemies and breaking destructible boxes) we need to call several methods on its scripts, but the Script Referencing issue prevents us from calling them directly so we use
|
We also need some non-serialized fields to remember the index of the current attack (to play the next one in the sequence when the player attacks repeatedly) and keep a direct reference to that attack for use later on:
private int _CurrentAnimationIndex = int.MaxValue;
private ClipTransition _CurrentAnimation;
Animations
Expanding the foldout arrow next to each of the Transitions allows us to customise the Fade Duration
and End Time
of each attack.
Weapon Trails
Whenever we play one of the attack animations, we also want to show a trail of blue light behind the weapon swing. This is handled by the TimeEffect
script from the 3D Game Kit Lite which the Script Referencing issue prevents us from using directly. But rather than adding a UnityEvent
field as we have in other cases, we can make use of the fact that Transitions already have built in Animancer Events by simply creating an event at time 0 and using its Unity Event to call the TimeEffect.Activate
.
In this case the trails are separately animated meshes so if we wanted the ability to tweak the Speed
of the attack animation, we would also need to pass that speed onto the trail so it gets shown correctly in relation to the character.
Rather than using a separately animated mesh for the trails, many games generally use Unity's Trail Renderer component (or a custom script that works similarly) to have the trail dynamically follow wherever the weapon goes. This would avoid the speed issue and require less effort to develop, but it does somewhat limit your creative control over the exact appearance of the effect.
Attack Details
The 3D Game Kit Lite uses a very basic damage system where the player kills enemies and destroys objects in one hit and enemies cause the player to lose 1 health on contact out of 5 total. But many games allow different weapons to deal different amounts of damage, knock enemies away, and even cause them to Flinch for different amounts of time. Some games even allow individual attacks with the same weapon to use different stats such as having a sword deal Slashing damage with one attack then Piercing damage with the next or having a weak bash attack followed by a powerful overhead strike.
Associating additional details like that with each animation can be done by creating a class that Inherits from ClipTransition
, just like how the Facial Animations sample gives each transition an extra Name
field.
The Platformer Game Kit has a more detailed damage system.
Adding More Attacks
Much like if we want to add more idle animations, building this script to use an array makes it much easier to modify the general behaviour and set up any number of animations without needing to set up the same states, transitions, and StateMachineBehaviour
s every time as you would when using Mecanim.
State Entry
In this sample, we only allow attacking while grounded:
public override bool CanEnterState => Character.Movement.IsGrounded;
Games often have different attacks they can use while Airborne as well, which could be achieved by giving this state an Inspector bool
to determine whether it's for ground or air attacks and attaching two of them to the same Character
, or by giving it a second array of ClipTransition
s to use when airborne.
When entering this state, we start by determining which attack in the sequence we are up to. If the previous attack was the last one or it has already fully faded out, we want the first attack, but otherwise we can go to the next one in line:
protected virtual void OnEnable()
{
if (_CurrentAnimationIndex >= _Animations.Length - 1 ||
_Animations[_CurrentAnimationIndex].State.Weight == 0)
{
_CurrentAnimationIndex = 0;
}
else
{
_CurrentAnimationIndex++;
}
Note how we initialized the index to start at int.MaxValue
when declaring the field so that the first condition checked by OnEnable
is true the first time it's used.
Now that we know which attack is starting, we can play it:
_CurrentAnimation = _Animations[_CurrentAnimationIndex];
Animancer.Play(_CurrentAnimation);
Character.ForwardSpeed = 0;
_OnStart.Invoke();
}
Updates
While this state is active, we want to apply the raw root motion from the attack animations so we disable FullMovementControl
:
public override bool FullMovementControl => false;
This FixedUpdate
method is the simplest of any state, we just check the standard transitions (Idle, Locomotion, or Airborne as appropriate) and turn in the direction the Character.Brain
wants to go:
protected virtual void FixedUpdate()
{
if (Character.CheckMotionState())
return;
Character.Movement.TurnTowards(Character.Parameters.MovementDirection, _TurnSpeed);
}
On its own, CheckMotionState
would immediately go to a different state, however we want to prevent other actions from interrupting attacks until they are done so we override CanExitState
to base its answer on the time of the current attack:
public override bool CanExitState
=> _CurrentAnimation.State.NormalizedTime >= _CurrentAnimation.EndNormalizedTime;
This means that once the specified time passes, the next FixedUpdate
will change to one of the standard states (and that state will be responsible for fading in its own animation). If the Input Buffer is attempting to attack again, it will then do so next time it's updated, which will enter this state again. Since the attack animation that just ended would still be fading out at that point, the OnEnable
method would then move it onto the next attack in the sequence.
We're using the End Event time to determine when this state is alowed to exit which makes it seem like we could just use an actual event instead of calling Character.CheckMotionState
every frame, but that wouldn't work because:
- If
CanExitState
always returns false,Character.CheckMotionState
won't work because it usesTrySetState
instead ofForceSetState
. - If
CanExitState
always returns true, it would allow other actions like jumping in the middle of an attack.