Making Space Gal Move - Part 3: It Works


I figured it out. The shitty part is that I don't 100% understand why it works.

I rewrote the collision code from scratch, and discovered, to my chagrin, that the new holistic raycasting method had the exact same problem of getting hung up on corners. What I eventually deduced is that it has to do with the raycasts higher on the bounding box getting snagged on the upper edge, so I just prohibited all the raycasts except the bottom ones from registering a hit on surfaces facing directly up.

Ultimately, I don't have the energy or the expertise to explain how I did it or why, so here's the full source code for my physics system.

using UnityEngine;
using System;
using System.Collections.Generic;
/// <summary>
/// A custom Rigidbody for platformer physics.
/// </summary>
public class GBody : MonoBehaviour 
{
    //---------------------
    // Static Properties
    //---------------------
    //---------------------
    // Members
    //---------------------
    
    public Collider2D myCollider;
    public GParticle particle;
    [Space]
    public bool isSolid;
    public bool acceptsInput;
    public Verb collisionVerb;
    [Space]
    public Vector2 input;
    public Vector2 velocity;
    [Space]
    public float gravity;
    public float terminalVelocity;
    public float hFriction;
    public float maxSpeed;
    public int maxSlope;
    public bool isGrounded;
    public bool isWallhanging;
    public float wallhangModifier;
    /// <summary>
    /// Value guide: for x, 1 = blocked to the right, -1 = blocked to the left, 2 is blocked both ways
    ///              for y, 1 = blocked from above, -1 = blocked from below, 2 is blocked both ways
    ///              for both, 0 is not blocked at all.
    /// </summary>
    public Vector2 movementBlock;
    // Private Members 
    private Actor parent;
    /// <summary>
    /// For gamefeel movement 
    /// </summary>
    public Vector2 adjustment;
    public bool isAdjusting;
    public int adjustmentTime;
    public int adjustmentCount;
    public Vector2 overrider;
    //---------------------
    // Events
    //---------------------
    //---------------------
    // Properties
    //---------------------
    //---------------------
    // Pseudo-Properties
    //---------------------
    
    //---------------------
    // MonoBehavior Methods
    //---------------------
    // Use this for initialization
    void Start () 
    {
        parent = GetComponent<actor>();
    }
    
    // Update is called once per frame
    void FixedUpdate () 
    {
        Vector2 modifier = new Vector2();
        float hDamper = 1;
        float vDamper = 1;
        
        if (acceptsInput)
        {
            Wallhang();
            if (input.x == 0)
            {
                hDamper = hFriction;
            }
            //Eventually we'll need to check if we're on a ladder, but for now, we'll just always zero out input.y
            input.y = 0;
            modifier = input;
        }
        
        velocity += modifier;
        velocity.x = Mathf.Clamp(velocity.x * hDamper,-maxSpeed,maxSpeed);
        velocity.y = velocity.y * vDamper;
        Gravity();
        
        CheckCollisions();
        
        UpdatePosition();
    }
    
    //---------------------
    // Public Methods
    //---------------------
    public void ApplyForce(Vector2 force)
    {
        velocity += force;
    }
    //---------------------
    // Private Methods
    //---------------------
    
    /// <summary>
    /// Checks if the Collider is currently touching any other colliders.
    /// </summary>
    private void CheckCollisions()
    {
        adjustment = Vector2.zero;
        PreemptiveCollision();
        movementBlock = Vector2.zero;
        
        GameObject hit = null;
        // Used for adjusting the check location, because of funkiness with how the coordinates work.
        // This is basically more shitty number twiddling, but putting it into a holder like this makes it okay /s
        Vector2 offset = new Vector2();
        if(velocity.y > 0)
        {
            offset.y = 1;
        }
        if(velocity.x < 0)
        {
            offset.x = -1;
        }
        
        // Check the space either above or below the collider
        for (int i = (int)-myCollider.bounds.extents.x; i < myCollider.bounds.extents.x; ++i)
        {
            hit = Check(new Vector2(myCollider.transform.position.x + i, myCollider.transform.position.y + offset.y + myCollider.bounds.extents.y * GTools.PosNeg(velocity.y)));
            if (hit && hit != myCollider.gameObject)
            {
                if (isSolid)
                {
                    movementBlock.y = 1 * GTools.PosNeg(velocity.y);
                }
                parent.Collide(hit);
            }
        }
        
        // More number finicking but this makes it look less like a kludge!!!
        offset.y = 1;
        // Check the space either to the left or right of the collider
        bool madeHit = false;
        for (int i = (int)-myCollider.bounds.extents.y; i < myCollider.bounds.extents.y;++i)
        {
            hit = Check(new Vector2(myCollider.transform.position.x + offset.x + myCollider.bounds.extents.x * GTools.PosNeg(velocity.x), myCollider.transform.position.y + i + offset.y));
            if (hit && hit != myCollider.gameObject)
            {
                if (isSolid)
                {
                    madeHit = true;
                }
                parent.Collide(hit);
            }
            else if (!hit && Mathf.Abs(i - -myCollider.bounds.extents.y) <= maxSlope && madeHit)
            {
                // This code is responsible for slopes, and as a side-effect, makes movement over edges a little smoother.
                adjustment.y = Mathf.Abs(i - -myCollider.bounds.extents.y);
                madeHit = false;
                break;
            }
        }
        if (madeHit)
        {
            movementBlock.x = 1 * GTools.PosNeg(velocity.x);
        }
    }
    /// <summary>
    /// Checks for collisions that will occur during the current frame, to prevent interpenetration.
    /// </summary>
    private void PreemptiveCollision()
    {
        overrider = Vector2.zero;
        LayerMask mask = 1 << gameObject.layer;
        mask = ~mask; //Inverts the bits
        Vector2 checkadjuster = new Vector2(0.5f, -0.5f);
        // We're going to check points on the corners and middle edges of the collider.
        // No really clean way to do this, just gotta enter the values manually.
        // It uses the Unity bounding box, so this code does not need to be adjusted
        // for different sizes.
        // This code is meant to unify the physics systems, so we can collide with 2D or 3D colliders,
        // and unfortunately that means twice as many checks because RaycastHit2D and RaycastHit do
        // not a common inheritance.
        List<tuple<raycasthit2d, vector2="">> hits2D = new List<tuple<raycasthit2d, vector2="">>();
        List<tuple<raycasthit, vector2="">> hits3D = new List<tuple<raycasthit, vector2="">>();
        Vector2 closestDistance = new Vector2(256,256); //Set very high so that any found distance will be closer.
        // Number finicking zone. Watch out!!!
        // Top Side Point
        Vector2 topSidePoint = new Vector2(myCollider.transform.position.x + (myCollider.bounds.extents.x * GTools.PosNeg(velocity.x)) ,
            myCollider.transform.position.y + myCollider.bounds.extents.y);
        // Middle Side Point
        Vector2 middleSidePoint = new Vector2(myCollider.transform.position.x + (myCollider.bounds.extents.x * GTools.PosNeg(velocity.x)) ,
            myCollider.transform.position.y);
        // Bottom Side Point
        Vector2 bottomSidePoint = new Vector2(myCollider.transform.position.x + (myCollider.bounds.extents.x * GTools.PosNeg(velocity.x)) ,
            myCollider.transform.position.y - myCollider.bounds.extents.y);
        // Middle Point
        Vector2 middlePoint = new Vector2(myCollider.transform.position.x,
            myCollider.transform.position.y + (myCollider.bounds.extents.y * GTools.PosNeg(velocity.y)));
        hits2D.Add(new Tuple<raycasthit2d, vector2="">( Physics2D.Raycast(topSidePoint, velocity, velocity.magnitude, mask),topSidePoint));
        hits2D.Add(new Tuple<raycasthit2d, vector2="">(Physics2D.Raycast(middleSidePoint, velocity, velocity.magnitude, mask),middleSidePoint));
        hits2D.Add(new Tuple<raycasthit2d, vector2="">(Physics2D.Raycast(bottomSidePoint, velocity, velocity.magnitude, mask),bottomSidePoint));
        hits2D.Add(new Tuple<raycasthit2d, vector2="">(Physics2D.Raycast(middlePoint, velocity, velocity.magnitude, mask),middlePoint));
        RaycastHit hit3d1 = new RaycastHit();
        Physics.Raycast(topSidePoint, velocity, out hit3d1, velocity.magnitude, mask);
        hits3D.Add(new Tuple<raycasthit, vector2="">(hit3d1, topSidePoint));
        RaycastHit hit3d2 = new RaycastHit();
        Physics.Raycast(middleSidePoint, velocity, out hit3d2, velocity.magnitude, mask);
        hits3D.Add(new Tuple<raycasthit, vector2="">(hit3d2, middleSidePoint));
        RaycastHit hit3d3 = new RaycastHit();
        Physics.Raycast(bottomSidePoint, velocity, out hit3d3, velocity.magnitude, mask);
        hits3D.Add(new Tuple<raycasthit, vector2="">(hit3d3, bottomSidePoint));
        RaycastHit hit3d4 = new RaycastHit();
        Physics.Raycast(middlePoint, velocity, out hit3d4, velocity.magnitude, mask);
        hits3D.Add(new Tuple<raycasthit, vector2="">(hit3d4, middlePoint));
        foreach(Tuple<raycasthit2d,vector2> it in hits2D)
        {
            if (it.Item1.collider)
            {
                //This code is a little cryptic. Basically we're just trying to find the shortest raycast.
                // The business with normals is because the higher-originating raycasts have a bad habit of
                // snagging on corners. I don't understand 100% why it works, but it works.
                if(it.Item1.distance < closestDistance.magnitude && it.Item1.distance != 0 && !(it.Item1.normal == new Vector2(0,1) && it.Item2.y >= myCollider.transform.position.y ))
                {
                    closestDistance = it.Item1.point - it.Item2;
                }
            }
        }
        foreach (Tuple<raycasthit, vector2=""> it in hits3D)
        {
            if (it.Item1.collider)
            {
                if (it.Item1.distance < closestDistance.magnitude && it.Item1.distance != 0 && !((Vector2)it.Item1.normal == new Vector2(0, 1) && it.Item2.y >= myCollider.transform.position.y))
                {
                    closestDistance = (Vector2)it.Item1.point - it.Item2;
                }
            }
        }
        if(closestDistance != new Vector2(256,256))
        {
            overrider = closestDistance;
            if(overrider.x < Mathf.Round(overrider.x))
            {
                overrider.x -= (Mathf.Round(overrider.x) - overrider.x);
            }
            else
            {
                overrider.x = Mathf.Round(overrider.x);
            }
            if (overrider.y < Mathf.Round(overrider.y))
            {
                overrider.y -= (Mathf.Round(overrider.y) - overrider.y);
            }
            else
            {
                overrider.y = Mathf.Round(overrider.y);
            }
            Restrict(ref overrider);
        }
    }
    private void Gravity()
    {
        if (adjustment != Vector2.zero)
        {
            isAdjusting = true;
            adjustmentCount = adjustmentTime;
        }
        float mod = 1;
        if(isWallhanging)
        {
            velocity.y = Mathf.Clamp(velocity.y, terminalVelocity * wallhangModifier, 0);
        }
        Gravity(mod);
        if (adjustment == Vector2.zero && adjustmentCount == 0)
        {
            isAdjusting = false;
        }
        adjustmentCount = Mathf.Clamp(adjustmentCount - 1, 0, 256);
    }
    private void Gravity(float modifier)
    {
        if (movementBlock.y == -1 || movementBlock.y == 2)
        {
            //Debug.Log("Grounded");
            isGrounded = true;
        }
        else
        {
            if (!isAdjusting)
            {
                //Debug.Log(gameObject.name + " is airborne");
                velocity.y -= gravity * modifier;
                velocity.y = Mathf.Clamp(velocity.y, terminalVelocity, 256);
                isGrounded = false;
            }
        }
    }
    private void Wallhang()
    {
        GameObject hit = Check(new Vector2(myCollider.transform.position.x + (GTools.PosNeg(input.x) * myCollider.bounds.extents.x) + input.x, myCollider.transform.position.y));
        if (!isGrounded && (movementBlock.x == input.x || movementBlock.x == 2) && input.x != 0)
        {
            if (hit && hit != myCollider)
            {
                isWallhanging = true;
            }
        }
        else
        {
            isWallhanging = false;
        }
        
    }
    private void UpdatePosition()
    {
        Vector3 oldPosition = transform.localPosition;
        
        Restrict(ref velocity);
        float modX = velocity.x;
        float modY = velocity.y;
        // If the preemptive checking found something, we're going to totally override the velocity.
        if(overrider != Vector2.zero)
        {
            modX = overrider.x;
            modY = overrider.y;
        }
        // The slopes adjustments are applied on top.
        if (adjustment != Vector2.zero)
        {
            modX += adjustment.x;
            modY += adjustment.y;
        }
        
        transform.localPosition = new Vector3(transform.localPosition.x + modX, transform.localPosition.y + modY);
        // Rounds off the position of the body if it hasn't moved since the last frame.
        // This ensures consistency in things like jump height from the body's apparent height.
        // Omit this if you're not handling positions in full integers.
        if (transform.localPosition.x == oldPosition.x)
        {
            transform.localPosition = new Vector3(Mathf.Round(transform.localPosition.x), transform.localPosition.y);
        }
        if (transform.localPosition.y == oldPosition.y)
        {
            transform.localPosition = new Vector3(transform.localPosition.x, Mathf.Round(transform.localPosition.y));
        }
    }
    //---------------------
    // Static Methods
    //---------------------
    /// <summary>
    /// Checks a given pixel coordinate for colliders.
    /// </summary>
    /// <param name="coords">
    /// <returns></returns>
    public GameObject Check(Vector2 coords)
    {
        LayerMask mask = 1 << gameObject.layer;
        mask = ~mask; //Inverts the bits
        return Check(coords, mask);
    }
    /// <summary>
    /// Checks a given pixel coordinate for colliders.
    /// </summary>
    /// <param name="coords">
    /// <returns></returns>
    public GameObject Check(Vector2 coords,int layerMask)
    {
        // THIS IS A VERY NAUGHTY FIX, BUT LET'S GIVE IT A SHOT!!!
        coords.x += 0.5f;
        coords.y -= 0.5f;
        RaycastHit2D hit2D = Physics2D.Raycast(coords, Vector2.down, 0.01f,layerMask);
        if (hit2D.collider != null && hit2D.collider.GetComponentInParent<gbody>() && !hit2D.collider.GetComponentInParent<gbody>().isSolid)
        {
            return null;
        }
        Collider[] hits = Physics.OverlapSphere(coords, 0.1f);
        foreach (Collider it in hits)
        {
            if (it != myCollider)
            {
                return it.gameObject;
            }
        }
        if (hit2D.collider)
        {
            return hit2D.collider.gameObject;
        }
        return null;
        
    }
    public void Restrict(ref Vector2 input)
    {
        if (movementBlock.y == -1)
        {
            input.y = Mathf.Clamp(input.y, 0, 256);
        }
        if (movementBlock.y == 1)
        {
            input.y = Mathf.Clamp(input.y, -256, 0);
        }
        if (movementBlock.x == -1)
        {
            input.x = Mathf.Clamp(input.x, 0, 256);
        }
        if (movementBlock.x == 1)
        {
            input.x = Mathf.Clamp(input.x, -256, 0);
        }
    }
    //---------------------
    // Event Methods
    //---------------------
}

And here's the code for GTools.PosNeg, which the above code will not work very well without:

public static int PosNeg(float input)
    {
        if(input > 0)
        {
            return 1;
        }
        if (input < 0)
        {
            return -1;
        }
        return 0;
    }

Leave a comment

Log in with itch.io to leave a comment.