Friends, food, and flourishing

One-sided 3D collider (kinda) in Unity

My daughter Teagan is the Jumping Queen. She tries jumping on everything in a game level. She isn’t the only one. Hannah, one of my students, does the same. Simon is jumpy, too. It’s a Thing, apparently.

There are many animals in CK’s cathedral level.

Animals to jump on.

They’ll show you a knowledge nugget if you interact with them. About religion. (There’s a chance it’ll offend you.)

I wanted to add something to let you jump on the animals. If you did, there’d be a puff of smoke, a sound, and you’d fly up and a little downrange.

So, I added a child game object (GO) to the animal, with a trigger collider.

Hierarchy

Trigger collider

When the player hits the collider, launch them, and play FX. Easy enough, right? What could possibly go wrong?

Hmm.

When the player ran into the animal from the side, the FX would play. That’s not what I wanted. I wanted the FX only when they jumped on it.

There was another regular blocking collider (not a trigger) on the animal as well.

Two colliders

I tried messing around with the relative sizes of the colliders, so the player could only hit the launcher trigger collider from above, but that was too finicky to get right in all circumstances.

What I wanted was a collider that only triggered when the player fell from above. Triggering from the sides or bottom wouldn’t happen.

 I wanted a one-sided collider, essentially. Unity has them for 2D, but not 3D.

(I couldn’t get a nonconvex collider on a plane to work, as suggested in some forums. These days, Unity trigger colliders for planes have to be convex. 🙁 AFAIK.)

I managed to write a component for the task. The core logic:

When the player hits the launching collider:
    If the player is directly above the launching GO:
        Shoot the player up.
        Play FX.

Here’s what it looks like in the inspector.

Component

The game uses the ECM2 character controller.

The fields:

  • When Debug Mode is on, the component logs messages about its internals.
  • The launching object is the thing the player shoots up from.
  • Impulse is the launching force.
  • The sound key is the sound to play when launching the player, handled by a central audio manager.
  • The particle system plays when launching the player.

About the FPS Controller Testing field. The component has to work in two contexts:

  • The game, when GOs like CKAudioManager are available. They are defined in other scenes, like the loading scene.
  • Testing, when the loading scene is not, er, loaded.

When the game is running for realsies, the launcher uses the ECM2 GO from another scene. When I’m testing the scene in isolation, the launcher uses a test ECM2 GO in the current scene. That’s what the FPS Controller Testing field shown in the inspector is for. (BTW, another script turns off FPS Controller Testing on scene load, when not testing.)

Here’s the code, without using and namespace statements. I left in the code for testing and debug logging.

The usual disclaimer: I am not a Unity expert. All I claim is the code worked for me. There are probably better ways to do it.

				
					public class PlayerLauncher : MonoBehaviour
{
    // Want debug messages?
    public bool debugMode;

    [Tooltip("Object that shoots the player up when they jump on it.")]
    [SerializeField]
    [Required]
    private GameObject launchingObject;
    
    [SerializeField]
    [Required]
    [Tooltip("Launch velocity.")]
    private Vector3 impulse = new(0, 4f, 0);

    [SerializeField]
    [Required]
    [Tooltip("Sound to play when launching.")]
    private string launchSoundKey = "BubblePop";

    [SerializeField]
    [Required]
    [Tooltip("Particles to show on launch.")]
    private ParticleSystem launcherParticleSystem;
    
    private FPSController _fpsController;
    private CKAudioManager _audioManager;

    [SerializeField]
    [Required]
    private FirstPersonCharacter fpsControllerTesting;

    // True if testing the scene outside the game context
    private bool _sceneTesting;

    private void Awake()
    {
        // Testing the scene outside the game context?
        _sceneTesting = Utilities.GetAudioManager() == null;
        if (!_sceneTesting)
        {
            // Not testing, grab some cross-scene objects. 
            _fpsController = Utilities.GetFPSController();
            _audioManager = Utilities.GetAudioManager();
        }
    }

    private void OnTriggerEnter(Collider other)
    {
        LogDebugMessage($"PlayerLauncher.OnTriggerEnter: start: collide with {other.gameObject.name}");
        if (other.CompareTag(Utilities.PlayerTag))
        {
            LogDebugMessage("PlayerLauncher.OnTriggerEnter: player hit launch trigger");
            bool playerAboveLaunchingGameObject = IsPlayerAboveLaunchCollider();
            LogDebugMessage($"PlayerLauncher.OnTriggerEnter: is player above trigger? {playerAboveLaunchingGameObject}");
            if (playerAboveLaunchingGameObject)
            {
                LogDebugMessage("PlayerLauncher.OnTriggerEnter: launching");
                if (_sceneTesting)
                {
                    fpsControllerTesting.LaunchCharacter(impulse);
                }
                else
                {
                    _fpsController.LaunchCharacter(impulse);
                    _audioManager.PlayFX(launchSoundKey, gameObject);
                }
                launcherParticleSystem.Play();
            }
        }
    }

    /// <summary>
    /// Is the player directly above the GO that launches the player?
    /// </summary>
    /// <returns>True if a sphere cast down from the player's capsule collider hits the launching GO.</returns>
    private bool IsPlayerAboveLaunchCollider()
    {
        // Get the radius of the player's capsule collider.
        Character player = _sceneTesting ? fpsControllerTesting : _fpsController;
        CapsuleCollider capsuleCollider = player.GetComponent<CapsuleCollider>();
        float playerRadius = capsuleCollider.radius;
        // Get the player's position.
        // return true;
        Transform playerTransform = 
                _sceneTesting ? fpsControllerTesting.transform
                    : _fpsController.transform;
        Vector3 playerPosition = playerTransform.position;
        // Move the Y up a bit to allow for player dropping through the trigger collider.
        playerPosition.y += 2f;
        // Sphere cast down.
        RaycastHit[] hits = Physics.SphereCastAll(playerPosition, playerRadius, Vector3.down, 8f);
        // Did the ray hit the launching GO?
        foreach (var hit in hits)
        {
            if (hit.collider.gameObject == launchingObject)
            {
                LogDebugMessage("PlayerLauncher.IsPlayerAboveLaunchCollider? Yes");
                return true;
            }
        }
        LogDebugMessage("PlayerLauncher.IsPlayerAboveLaunchCollider? No");
        return false;
    }

    /// <summary>
    /// If debugging, log to console or log file, depending on whether this is a
    /// Real Game or a test.
    /// </summary>
    /// <param name="message">What to log</param>
    private void LogDebugMessage(string message)
    {
        if (debugMode)
        {
            if (_sceneTesting)
            {
                Debug.Log(message);
            }
            else
            {
                Utilities.LogDebugMessage(message);
            }
        }
    }
}

				
			

The key is the method IsPlayerAboveLaunchCollider(). It does a sphere cast down from the player. If it hits the launching GO, the method returns true.

I tried a ray cast first, but it had literal edge cases. The ray comes down from the center of the player GO. If the player jumps on the edge of the launching collider, the ray wouldn’t hit the launching object. It would be just to the side.

Missed!

So, I cast a sphere of the same radius as the player.

One problem is OnTriggerEnter() runs on the first frame where another collider overlaps with the launcher collider. Will the player’s transform’s position be above the launching GO in all cases? Probably, unless the game is running on a slow device. Like, really slow, but that could happen. So, line 88 pushes the sphere cast’s origin up quite a bit, beyond what should be needed for slow devices.

That’s it! CK’s getting more Fun FX. Yay!

Leave a Reply

Your email address will not be published. Required fields are marked *

css.php