encompass-cs-docs/content/pong/ball/bouncing/motion_engine.md

9.0 KiB

title date weight
Motion Engine: The Revenge 2019-05-28T18:01:49-07:00 500

Before we begin you might want to skim the docs for Bonk. But the short version of what you need to know is that SpatialHash is a structure that lets us do fast, inaccurate checks to quickly eliminate potential collision checks.

First, we add entities with CollisionComponents to the SpatialHash.

Next, we consolidate MotionMessages by their entities to get a total movement value for each entity.

Finally, we go over all entities with a PositionComponent and CollisionComponent, and sweep over the distance it has moved to check for collisions. If any entity would overlap with another, we adjust its movement to prevent it from overlapping and dispatch a CollisionMessage.

First let's create a CollisionComponent. In PongFE/Components/CollisionComponent.cs:

using Encompass;
using MoonTools.Bonk;

namespace PongFE.Components
{
    public struct CollisionComponent : IComponent
    {
        public Rectangle Rectangle { get; }

        public CollisionComponent(Rectangle rectangle)
        {
            Rectangle = rectangle;
        }
    }
}

This is pretty straightforward. We just pass in a Bonk Rectangle.

Let's rewrite our MotionEngine.

First, let's create a Bonk SpatialHash. Every frame we will empty the hash and re-add all relevant entities.

In PongFE/Engines/MotionEngine.cs:

using System.Collections.Generic;
using System.Numerics;
using Encompass;
using MoonTools.Bonk;
using MoonTools.Structs;
using PongFE.Components;
using PongFE.Messages;

namespace PongFE.Engines
{
    [QueryWith(typeof(PositionComponent), typeof(CollisionComponent))]
    public class MotionEngine : Engine
    {
        private readonly SpatialHash<Entity> _spatialHash = new SpatialHash<Entity>(32);

        public override void Update(double dt)
        {
            _spatialHash.Clear();

            foreach (var entity in TrackedEntities)
            {
                ref readonly var positionComponent = ref GetComponent<PositionComponent>(entity);
                ref readonly var collisionComponent = ref GetComponent<CollisionComponent>(entity);

                _spatialHash.Insert(entity, collisionComponent.Rectangle, new Transform2D(positionComponent.Position));
            }
        }
    }
}

First, at the very beginning of the processing, we insert all of our entities with a Position and Collision into the SpatialHash.

Next, in a separate block, let's consolidate our MotionMessages per Entity.

    ...

    private readonly Dictionary<Entity, Vector2> _moveAmounts = new Dictionary<Entity, Vector2>();

    ...

    foreach (var entity in TrackedEntities)
    {
        ...
    }

    foreach (ref readonly var entity in ReadEntities<PositionComponent>())
    {
        ref readonly var positionComponent = ref GetComponent<PositionComponent>(entity);

        _moveAmounts[entity] = Vector2.Zero;
        foreach (var motionMessage in ReadMessagesWithEntity<MotionMessage>(entity))
        {
            _moveAmounts[entity] += motionMessage.Movement;
        }
    }

    ...

This is where our IHasEntity optimization comes in. It allows us to use the ReadMessagesWithEntity method. This method efficiently queries messages that refer to the given entity.

Finally, let's implement our sweep test.

    private (bool, bool, Position2D, Entity) SolidCollisionPosition(Rectangle rectangle, Position2D startPosition, Position2D endPosition)
    {
        var startX = startPosition.X;
        var endX = endPosition.X;

        var startY = startPosition.Y;
        var endY = endPosition.Y;

        bool xHit, yHit;
        int xPosition, yPosition;
        Entity xCollisionEntity, yCollisionEntity;

        (xHit, xPosition, xCollisionEntity) = SweepX(_spatialHash, rectangle, Position2D.Zero, new Position2D(startX, startY), endX - startX);
        if (!xHit) { xPosition = endX; }
        (yHit, yPosition, yCollisionEntity) = SweepY(_spatialHash, rectangle, Position2D.Zero, new Position2D(xPosition, startY), endY - startY);

        return (xHit, yHit, new Position2D(xPosition, yPosition), xHit ? xCollisionEntity : yCollisionEntity);
    }

    private (bool, int, Entity) SweepX(SpatialHash<Entity> solidSpatialHash, Rectangle rectangle, Position2D offset, Position2D startPosition, int horizontalMovement)
    {
        var sweepResult = SweepTest.Test(solidSpatialHash, rectangle, new Transform2D(offset + startPosition), new Vector2(horizontalMovement, 0));
        return (sweepResult.Hit, startPosition.X + (int)sweepResult.Motion.X, sweepResult.ID);
    }

    public static (bool, int, Entity) SweepY(SpatialHash<Entity> solidSpatialHash, Rectangle rectangle, Position2D offset, Position2D startPosition, int verticalMovement)
    {
        var sweepResult = SweepTest.Test(solidSpatialHash, rectangle, new Transform2D(offset + startPosition), new Vector2(0, verticalMovement));
        return (sweepResult.Hit, startPosition.Y + (int)sweepResult.Motion.Y, sweepResult.ID);
    }

Here we use Bonk's SweepTest functionality. A sweep test moves a rectangle along a vector, checking for collisions along the movement of the sweep. First we sweep in a horizontal direction, and then in a vertical direction, returning the positions where collisions occurred. This means that objects won't awkwardly stop in place when they touch something.

{{% notice note %}} In the future you might want to change how the sweep tests resolve a position, and this would certainly be possible using this system. You could have different components like SlideOnCollisionComponent or StickOnCollisionComponent, for example, that would affect the returned sweep position. {{% /notice %}}

Now that we have a mechanism for detecting sweep hits, we can send out a CollisionMessage and UpdatePositionMessage.

In PongFE/Messages/CollisionMessage.cs

using Encompass;

namespace PongFE.Messages
{
    public enum HitOrientation
    {
        Horizontal,
        Vertical
    }

    public struct CollisionMessage : IMessage
    {
        public Entity EntityA { get; }
        public Entity EntityB { get; }
        public HitOrientation HitOrientation;

        public CollisionMessage(Entity a, Entity b, HitOrientation hitOrientation)
        {
            EntityA = a;
            EntityB = b;
            HitOrientation = hitOrientation;
        }
    }
}

It is useful to differentiate between a horizontal hit and and a vertical hit, so we set up an enum to track that.

And in PongFE/Messages/UpdatePositionMessage.cs

using Encompass;
using MoonTools.Structs;

namespace PongFE.Messages
{
    public struct UpdatePositionMessage : IMessage, IHasEntity
    {
        public Entity Entity { get; }
        public Position2D Position { get; }

        public UpdatePositionMessage(Entity entity, Position2D position)
        {
            Entity = entity;
            Position = position;
        }
    }
}

This is pretty straightforward. We'll use this message to set an entity's position.

In PongFE/Engines/UpdatePositionEngine.cs:

using Encompass;
using PongFE.Components;
using PongFE.Messages;

namespace PongFE.Engines
{
    [Receives(typeof(UpdatePositionMessage))]
    [Writes(typeof(PositionComponent))]
    public class UpdatePositionEngine : Engine
    {
        public override void Update(double dt)
        {
            foreach (ref readonly var message in ReadMessages<UpdatePositionMessage>())
            {
                SetComponent(message.Entity, new PositionComponent(message.Position));
            }
        }
    }
}

Now, in the final block of our MotionEngine, we can perform our collision tests and send out our messages.

    ...

    foreach (var entity in TrackedEntities)
    {
        ...
    }
    
    foreach (ref readonly var entity in ReadEntities<PositionComponent>())
    {
        ...
    }

    foreach (var entity in TrackedEntities)
    {
        Vector2 moveAmount = _moveAmounts[entity];

        ref readonly var positionComponent = ref GetComponent<PositionComponent>(entity);
        var projectedPosition = positionComponent.Position + moveAmount;

        ref readonly var collisionComponent = ref GetComponent<CollisionComponent>(entity);
        var rectangle = collisionComponent.Rectangle;
        var (xHit, yHit, newPosition, collisionEntity) = SolidCollisionPosition(rectangle, positionComponent.Position, projectedPosition);

        if (xHit || yHit)
        {
            projectedPosition = newPosition;

            if (xHit)
            {
                SendMessage(new CollisionMessage(entity, collisionEntity, HitOrientation.Horizontal));
            }
            else
            {
                SendMessage(new CollisionMessage(entity, collisionEntity, HitOrientation.Vertical));
            }
        }


        SendMessage(new UpdatePositionMessage(entity, projectedPosition));
    }

Here, we go over everything with a Position and Collision component, sweep test for collisions, and send appropriate Collision and UpdatePosition messages accordingly.

Now let's handle those collision messages.