gif_walker_perimeter

We’ve had ground-based enemies, which we call Walkers, in Lovers since way back in the days of the GDC 2013 build. Until recently these enemies have been tethered to spherical (well, circular) planets, so programming their movement was simply a matter of ensuring that their distance from the center of the planet was constant and their velocity was tangential to the vector from the enemy’s position to the planet’s center. However, as we continued to add new scenarios for players to experience we needed Walkers to be able to traverse more exotic terrain. Being the lazy developers that we are, our first attempt to implement a more robust walking algorithm was the simplest and most naive that we could come up with. Luckily for us, it worked out pretty well.

old_planet

Our naivety lead us to use raycasts to check for a collider underneath a Walker. Shooting one ray from the center may work for some situations, but the Walker would look squirrely as it moves across significant changes in terrain. Instead we shoot two rays, one from each side of the Walker:

bool doubleRaycastDown(TerrainMovementRayProperties movementRay, float rayLength,
        out RaycastHit leftHitInfo, out RaycastHit rightHitInfo)
{
    Vector3 transformUp = transform.up;
    Vector3 transformRight = transform.right;
    Ray leftRay = new Ray(transform.position + movementRay.originOffsetY * transformUp
        + movementRay.distanceFromCenter * transformRight, -transformUp);
    Ray rightRay = new Ray(transform.position + movementRay.originOffsetY * transformUp
        – movementRay.distanceFromCenter * transformRight, -transformUp);

    return Physics.Raycast(leftRay, out leftHitInfo, rayLength, DefaultTerrainLayerMask)
        && Physics.Raycast(rightRay, out rightHitInfo, rayLength, DefaultTerrainLayerMask);
}

If both rays hit a collider with the proper layer, we use take the average of the position and normal vectors of the resulting RaycastHit objects and use them to position and orient the Walker, respectively. Easy as that!

void positionOnTerrain(RaycastHit leftHitInfo, RaycastHit rightHitInfo,
        float maxRotationDegrees, float positionOffsetY)
{
    Vector3 averageNormal = (leftHitInfo.normal + rightHitInfo.normal) / 2;
    Vector3 averagePoint = (leftHitInfo.point + rightHitInfo.point) / 2;

    Quaternion targetRotation = Quaternion.FromToRotation(Vector3.up, averageNormal);
    Quaternion finalRotation = Quaternion.RotateTowards(transform.rotation, targetRotation,
        maxRotationDegrees);
    transform.rotation = Quaternion.Euler(0, 0, finalRotation.eulerAngles.z);

    transform.position = averagePoint + transform.up * positionOffsetY;
}

Well, almost as easy as that. Allowing the Walker to rotate an arbitrary amount each frame leads to very jittery-looking motion as it traverses the bumps and crevices. To combat this, we limit the amount it is allowed to rotate each frame using Quaternion.RotateTowards. This tweak goes a long way to allowing for more lifelike movement.

The Walker also had a tendency to get stuck in or clip through certain types of concave geometry. To overcome this, we added a very short horizontal ray in the direction of motion that extends to about the edge of the character’s body. If this ray collides with any piece of terrain, it will override the downward ray on that side of the Walker.

if(rigidbody.velocity.sqrMagnitude > 0)
{
    //if moving left
    if(MathUtilities.VectorSimilarity(rigidbody.velocity, selfTransform.right) > 0)
    {
        RaycastHit overrideLeftHitInfo;
        Ray overrideLeftRay = new Ray(transform.position
            + horizontalRayProperties.originOffsetY * selfTransform.up, selfTransform.right);
        if(Physics.Raycast(overrideLeftRay, out overrideLeftHitInfo,
            horizontalRayProperties.attachedRayLength, DefaultTerrainLayerMask))
        {
            leftHitInfo = overrideLeftHitInfo;
        }
    }
    //if moving right
    else
    {
        RaycastHit overrideRightHitInfo;
        Ray overrideRightRay = new Ray(transform.position
            + horizontalRayProperties.originOffsetY * selfTransform.up, -selfTransform.right);
        if(Physics.Raycast(overrideRightRay, out overrideRightHitInfo,
            horizontalRayProperties.attachedRayLength, DefaultTerrainLayerMask))
        {
            rightHitInfo = overrideRightHitInfo;
        }
    }
}

Below is the system in motion. The yellow and red lines represent the rays being shot from the Walker, the green and magenta lines are the normals generated from those rays’ collisions with terrain and the cyan line is the average of those normals, originating at the average terrain intersection point of the Walker rays. (The horizontal override ray is not shown.)

gif_walker_rays

The asteroid the enemy is walking on is a compound collider consisting of sphere and capsule colliders, but this system works equally well on an arbitrary mesh collider.