One of the trickiest parts of the game is handling AI movement, mostly because I use a hybrid solution made of:
- Root motion animations for busy actions such as attacks, spell casting, dodges, and parry reactions — mostly because there is no better movement than the one built directly into the animation itself, and this brings a much more natural fluidity to combat.
- NavMesh pathfinding, which is activated specifically for the Patrol and Chase states, since these depend on navigating the scene’s NavMesh paths. This is used only as a guide for the third piece of the puzzle.
- CharacterController, which acts as the actual motor that moves the AI around when root motion is not being used. This controller is fed with movement data coming from the NavMeshAgent’s pathfinding results.
This is the code I use to make all of this work together:
private void OnAnimatorMove()
{
Vector3 gravity = characterGravity != null && characterGravity.ignoreGravity
? new Vector3(0, characterGravity.initialY, 0)
: Physics.gravity;
if (animator.applyRootMotion && characterController.enabled)
{
// If the agent is enabled and we are not performing an action,
// use the NavMesh to guide the CharacterController
if (agent.enabled && !isBusy)
{
Vector3 worldDeltaPosition = agent.nextPosition - transform.position;
worldDeltaPosition.y = 0f;
Vector3 direction = worldDeltaPosition.normalized + gravity;
float speed = ShouldRun() ? chaseSpeed : patrolSpeed;
if (!characterController.isGrounded)
{
direction += agent.transform.forward * 2f;
}
if (agent.velocity.magnitude <= 0.1f)
{
speed = 0f;
}
characterController.Move(speed * Time.deltaTime * direction);
agent.nextPosition = transform.position;
HandleAgentRotation();
}
else
{
// Apply animator root rotation
transform.rotation *= animator.deltaRotation;
// Apply root motion position and gravity
Vector3 rootMotionPosition =
animator.deltaPosition + gravity * Time.deltaTime;
if (isCuttingDistanceToTarget)
{
HandleCuttingDistance(ref rootMotionPosition);
}
characterController.Move(rootMotionPosition);
}
}
}
HandleCuttingDistance is a helper function that makes the enemy dash toward the player, almost like a glide effect. This is great for making combat more challenging, since the enemy can close distances very quickly.
Why isn’t NavMesh alone enough?
Unity’s NavMeshAgent is great at pathfinding, but terrible if you want enemies to be pushed off the NavMesh area.
I iterated through multiple solutions, but none of them worked well. In the end, I found that only activating the NavMeshAgent for Patrol and Chase was enough, while letting other states be handled entirely by animations.
For example, if an enemy dodges using root motion, the NavMeshAgent is disabled, so there’s nothing preventing that enemy from falling off a cliff. Of course, this is one of the worse trade-offs of this approach — certain enemy actions can end up causing their own death.
There is definitely room for improvement here, such as turning dodge actions into proper state-machine states that enable the NavMeshAgent on state enter and disable it on state exit.
Common issues with this hybrid approach
One of the main issues I ran into was enemies getting stuck in certain areas. For this system to work properly, there are two very important things to take into account:
- CharacterController dimensions must be smaller than the NavMeshAgent’s dimensions
The controller’s radius and height must be smaller than the agent’s radius and height. Otherwise, the agent might find a valid path, but the CharacterController collides with the environment, causing the AI to get stuck — even though the agent itself “fits.” - Step height must match between the agent and the controller
I was still having issues even with correct dimensions, and eventually found the cause: the NavMeshAgent step height was set to0.5(50 cm), while the CharacterController’s step offset was only0.1(10 cm). This meant the agent could plan paths over steps that the CharacterController physically could not climb.
Because of this mismatch, the agent wanted to move forward, but the controller refused — resulting in a stuck AI.
These are some hard-conquered lessons after many iterations on this problem 🙂 I hope this helps someone out there who is trying to build Soulslike AI movement using a hybrid solution with NavMesh and CharacterController.
No comments:
Post a Comment
Faça-se ouvir. Diga o que acha a respeito deste assunto!