c# – 2D Continous Collision Detection in Monogame/Xna Framework

I’m working on a custom physics engine for a potential game – based in Monogame/the XNA framework. While the physics itself works pretty well, I’m running into an issue with collision. When the player comes out of a jump, they can often end up inside the floor. See image below. I did a couple hours of research on my own and found out that what I probably need is continous collision detection (CCD) similar to how something like Unity implement it, but all the questions I’ve found here or other places haven’t really worked, and neither has any of my solutions, so I’m asking the strangers on the internet who are smarter than me.

Here’s game 1:

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using MonoGame.Extended;
using AmethystDawn.Utilities;

namespace AmethystDawn
{
    internal class Game1 : Game
    {
        Texture2D spritesheet;
        Texture2D spritesheetFlipped;
        Texture2D activeSpritesheet;
        Texture2D platform;
        float timer; // millisecond timer
        int threshold;
        Rectangle[] sourceRectangles;
        byte previousAnimationIndex;
        byte currentAnimationIndex;
        RectangleF playerCollider;
        RectangleF groundCollider;

        PhysicsCalculator physics = new();

        Vector2 PlayerPos = new Vector2(0, 0);

        private GraphicsDeviceManager graphics;
        private SpriteBatch sprites;
        private SpriteFont font;

        public Game1() : base()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";
            IsFixedTimeStep = true;
            IsMouseVisible = true;
            IsFixedTimeStep = false;
        }

        protected override void Initialize()
        {
            base.Initialize();
        }

        protected override void LoadContent()
        {
            graphics.PreferredBackBufferWidth = GraphicsDevice.DisplayMode.Width;
            graphics.PreferredBackBufferHeight = GraphicsDevice.DisplayMode.Height;
            graphics.IsFullScreen = true;
            graphics.HardwareModeSwitch = false;
            graphics.ApplyChanges();
            sprites = new SpriteBatch(GraphicsDevice);
            font = Content.Load<SpriteFont>("Fonts/november");
            spritesheet = Content.Load<Texture2D>("Sprites/Player/player spritesheet");
            spritesheetFlipped = Content.Load<Texture2D>("Sprites/Player/player spritesheet flipped");
            platform = Content.Load<Texture2D>("Sprites/platform");
            activeSpritesheet = spritesheet;
            timer = 0;
            threshold = 100;
            sourceRectangles = new Rectangle[4];
            sourceRectangles[0] = new Rectangle(0, 0, 32, 40);
            sourceRectangles[1] = new Rectangle(34, 0, 28, 40);
            sourceRectangles[2] = new Rectangle(66, 0, 28, 40);
            sourceRectangles[3] = new Rectangle(96, 0, 32, 40);
            previousAnimationIndex = 2;
            currentAnimationIndex = 1;
            base.LoadContent();
        }

        protected override void UnloadContent()
        {
            base.UnloadContent();
        }

        protected override void Update(GameTime gameTime)
        {
            if (timer > threshold) // check if the timer has exceeded the threshold
            {
                if (currentAnimationIndex == 1) // if sprite is in the middle sprite of the animation
                {
                    if (previousAnimationIndex == 0) // if the previous animation was the left-side sprite, then the next animation should be the right-side sprite
                    {
                        currentAnimationIndex = 2;
                    }
                    else
                    {
                        currentAnimationIndex = 0; // if not, then the next animation should be the left-side sprite
                    }
                    previousAnimationIndex = currentAnimationIndex;
                }
                else
                {
                    currentAnimationIndex = 1; // if not in the middle sprite of the animation, return to the middle sprite
                }
                timer = 0;
            }
            else
            {
                // if the timer has not reached the threshold, then add the milliseconds that have past since the last Update() to the timer
                timer += (float)gameTime.ElapsedGameTime.TotalMilliseconds;
            }

            base.Update(gameTime);
        }

        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);

            sprites.Begin();

            sprites.Draw(platform, new Vector2(0, GraphicsDevice.Viewport.Height - platform.Height - 50), Color.White);
            groundCollider = new RectangleF(0, GraphicsDevice.Viewport.Height - platform.Height - 50, platform.Width, platform.Height);

            var kstate = Keyboard.GetState();

            playerCollider = new(PlayerPos.X, PlayerPos.Y, sourceRectangles[currentAnimationIndex].Width, sourceRectangles[currentAnimationIndex].Height);
            if (IsColliding(groundCollider, playerCollider))
            {
                physics.UpdatePhysicValues(false);
                /*if (PlayerPos.Y + playerCollider.Height + 100 > groundCollider.Y)
                {
                    PlayerPos.Y = groundCollider.Y - groundCollider.Height;
                }*/
                if (kstate.IsKeyDown(Keys.Space))
                {
                    physics.Jump(3f);
                }
            }
            else
            {
                physics.UpdatePhysicValues(true);
                if (kstate.IsKeyDown(Keys.Space))
                {
                    physics.MidairJump(3f);
                }
                else
                {
                    physics.LockJump();
                }
            }

            if (kstate.IsKeyDown(Keys.A))
            {
                physics.ApplyWalkingForce(new Vector2(-1, 0), 0.5f);
                activeSpritesheet = spritesheetFlipped;
                sourceRectangles[0] = new Rectangle(0, 0, 32, 40);
                sourceRectangles[1] = new Rectangle(34, 0, 28, 40);
                sourceRectangles[2] = new Rectangle(66, 0, 28, 40);
                sourceRectangles[3] = new Rectangle(96, 0, 32, 40);
            }
            else if (kstate.IsKeyDown(Keys.D))
            {
                physics.ApplyWalkingForce(new Vector2(1, 0), 0.5f);
                activeSpritesheet = spritesheet;
                sourceRectangles[0] = new Rectangle(96, 0, 32, 40);
                sourceRectangles[1] = new Rectangle(66, 0, 28, 40);
                sourceRectangles[2] = new Rectangle(34, 0, 28, 40);
                sourceRectangles[3] = new Rectangle(0, 0, 32, 40);
            }
            else
            {

            }

            if (kstate.IsKeyDown(Keys.S) && !IsColliding(groundCollider, playerCollider))
            {
                physics.ApplyExtraGravity(1f);
            }

            if (kstate.IsKeyDown(Keys.R))
            {
                PlayerPos = new Vector2(0, 0);
            }

            PlayerPos = physics.position(PlayerPos);

            // is player on the bounds of the screen
            if (PlayerPos.X < 0)
            {
                PlayerPos.X = 0;
                physics.HitWall();
            }
            else if (PlayerPos.X > GraphicsDevice.Viewport.Width - 32)
            {
                PlayerPos.X = GraphicsDevice.Viewport.Width - 32;
                physics.HitWall();
            }
            sprites.Draw(activeSpritesheet, PlayerPos, sourceRectangles[currentAnimationIndex], Color.White, 0f, new Vector2(0, 0), 1f, SpriteEffects.None, 0f);
            sprites.End();


            base.Draw(gameTime);
        }

        private bool IsColliding(RectangleF rect1, RectangleF rect2)
        {
            return rect1.Intersects(rect2);
        }
    }
}

And here’s the physics calculator:

using System.Diagnostics;
using Microsoft.Xna.Framework;

namespace AmethystDawn.Utilities
{
    internal class PhysicsCalculator
    {
        private float directionalForce;
        private Vector2 direction;
        private const float directionalForceMax = 10f;
        private float walkingForce;
        private const float walkingForceMax = 0.5f;
        private float gravityForce;
        private const float gravityForceMax = 25f;
        private float jumpForce;
        private const float jumpForceMax = 5f;
        private int framesInAir;
        private const int framesInAirMax = 90;
        
        public void UpdatePhysicValues(bool falling)
        {
            if (directionalForce > 0)
            {
                directionalForce -= 0.5f;
            }

            if (walkingForce > 0)
            {
                walkingForce -= 0.02f;
            }
            else
            {
                walkingForce = 0;
            }

            if (gravityForce > jumpForce)
            {
                if (falling && !(gravityForce > gravityForceMax))
                {
                    gravityForce += 0.2f;
                }
                else if (!falling)
                {
                    gravityForce = 0;
                    direction.Y = 0;
                    framesInAir = 0;
                }
            }
            else
            {
                jumpForce -= 0.3f;
            }

            FixDirection();
        }

        public void ApplyDirectionalForce(Vector2 directionHit, float forceToApply)
        {
            direction += directionHit;
            directionalForce += forceToApply;
            if (directionalForce > directionalForceMax) directionalForce = directionalForceMax;
        }

        public void ApplyWalkingForce(Vector2 directionWalked, float forceToApply)
        {
            direction += directionWalked;
            walkingForce += forceToApply;
            if (walkingForce > walkingForceMax) walkingForce = walkingForceMax;
        }

        public void Jump(float force)
        {
            direction += new Vector2(0, -1);
            jumpForce += force;
            if (jumpForce > jumpForceMax) jumpForce = jumpForceMax;
        }

        public void MidairJump(float force)
        {
            framesInAir++;
            if (framesInAir > framesInAirMax) return;
            jumpForce += force;
            if (jumpForce > jumpForceMax) jumpForce = jumpForceMax;
        }

        public void LockJump()
        {
            framesInAir = framesInAirMax;
        }

        public void ApplyExtraGravity(float amount)
        {
            gravityForce += amount;
        }

        public Vector2 position(Vector2 currentPosition)
        {
            currentPosition += new Vector2(0, gravityForce);
            currentPosition += new Vector2(direction.X * directionalForce, direction.Y * directionalForce);
            currentPosition += new Vector2(direction.X * walkingForce, direction.Y * walkingForce);
            currentPosition += new Vector2(0, direction.Y * jumpForce);
            return currentPosition;
        }

        public void HitWall()
        {
            direction.X = 0;
        }

        private void CorrectGravity()
        {

        }

        private void FixDirection()
        {
            if (direction.X > 20) direction.X = 20;
            if (direction.Y > 20) direction.Y = 20;
            if (direction.X < -20) direction.X = -20;
            if (direction.Y < -15) direction.Y = -15;

            if (walkingForce <= 0 && directionalForce <= 0) direction.X = 0;
        }
    }
}

And the image:

Leave a Comment