-
Notifications
You must be signed in to change notification settings - Fork 1
The Collision Engine
So, you want to know how collisions work in AM2E. Collisions are handled by the Loj Object-Interface Collider (LOIC) and are based around the following principles:
- Everything should be pixel-perfect*. This means that there is no sub-pixel position for colliders, just sub-pixel velocity so that they end up in the correct location over time.
- You should not be able to clip into/through other colliders by moving too fast.
- The programmer defines the exact order in which a given collider will search for collisions.
- Collision events are run based on Interface/Class inheritance.
- Individual game objects are permitted to possess multiple hitboxes. 5.1) Hitboxes will match all of their owner's collision interfaces by default, but can be "bound" to only match a subset or none at all. 5.2) Hitboxes will search for matches against all collision interfaces registered with their owner by default, but can be "targeted" to only match a subset or none at all.
- The programmer decides when a given Collider will move and/or search for collisions.
Let's break these down one-by-one.
This is somewhat self-explanatory. We do not support sub-pixel positions for colliders or hitboxes, instead opting to store what the sub-pixel offset would be as a "sub-velocity." During movement, this is tallied up and used to move the collider an additional pixel (or more) when it exceeds 1 for a given axis.
*We do technically violate this with individual line checks, as I have not yet found a school of mathematics that can efficiently calculate line segment intersections and overlaps as mapped to a discrete grid of points. If you are aware of such mathematics, please reach out; I'd like to rewrite the line checks to avoid some edge cases.
If you've worked with other game engines before, you've likely encountered collision engines that apply velocity first in a single step and then test for hitbox overlaps. This is nice and efficient, but it is terribly inaccurate if you desire to fire a collision event at the precise moment intersection is about to occur.
AM2E accomplishes precise detection with a "collision substep" event that is run once for every pixel in a given movement operation. When a collision is detected, it will feed back a "collision direction" value into the Collider that can be accessed to gain some context as to the event.
This is also accomplished with the collision substep. The programmer must define the actual contents of the collision substep, using Collider.CheckAndRun<>() calls to run the actual collision event checks. This custom definition also allows for custom code to be run during or after each substep.
This is the OI part of the LOIC. The programmer may define "collision interfaces" (children of ICollider) that are then applied to various classes like "tags" to tell the collision engine what they are.
For example, your game will likely have an ISolid interface. Any class you want to be treated as solid ground, walls, and ceilings will implement ISolid; this enables LOIC collision checks for ISolid to fire when overlapping with that class' hitbox(es).
Here is my default ISolid interface and the Solid class:
public interface ISolid : ICollider { }
public sealed class Solid : ColliderBase, ISolid
{
public Solid(LDtkEntityInstance entity, int x, int y, Layer layer) :
base(entity, x, y, layer, new RectangleHitbox(x, y, entity.Width, entity.Height)) { }
}This is all that is required for the collision engine to detect instances of this class when you use checks like LOIC.CheckPoint<ISolid>(x, y) or collision callbacks in a Collider. Please note that your classes must inherit either ColliderBase or Actor to participate in the full collision engine.
Speaking of ColliderBase and Actor, I should probably explain the Collider system.
A Collider is a collection of hitboxes. Every ColliderBase and Actor has one. You may add hitboxes to a Collider as follows:
var rectangleHitbox = new RectangleHitbox(x, y, 16, 16);
Collider.AddHitbox(rectangleHitbox);
var circleHitbox = new CircleHitbox(x, y, 32);
Collider.AddHitbox(circleHitbox);This allows you to construct complex collision shapes from simple components without needing to micro-manage multiple Actors. However, this would be fairly limited on its own which brings us into the next topic:
5.1) Hitboxes will match all of their owner's collision interfaces by default, but can be "bound" to only match a subset or none at all.
As noted, every Hitbox added to a Collider will match collision checks for all collision interfaces implemented by their owner. However, the programmer is permitted to modify this behavior and apply only specific interfaces from the owner to specific hitboxes. Let's revisit our last example:
public class MyGameClass : ColliderBase, ISolid, IPlayerHurter
{
public MyGameClass(LDtkEntityInstance entity, int x, int y, Layer layer)
: base(entity, x, y, layer)
{
var rectangleHitbox = new RectangleHitbox(x, y, 16, 16);
rectangleHitbox.BindToInterface<ISolid>();
Collider.AddHitbox(rectangleHitbox);
var circleHitbox = new CircleHitbox(x, y, 32);
circleHitbox.BindToInterface<IPlayerHurter>();
Collider.AddHitbox(circleHitbox);
}
}With this configuration, only the RectangleHitbox will be considered a member of ISolid for other objects running collision checks, and only the CircleHitbox will be considered a member of IPlayerHurter.
5.2) Hitboxes will search for matches against all collision interfaces registered with their owner by default, but can be "targeted" to only match a subset or none at all.
In a similar vein to the binding system, there are scenarios where the programmer may desire for only one hitbox to count when searching for overlaps with a specific collision interface. Let's revisit the last example one more time:
public class MyActor : Actor, ISolid
{
public float VelX = 10;
public float VelY = -3;
public MyActor(LDtkEntityInstance entity, int x, int y, Layer layer)
: base(entity, x, y, layer)
{
var rectangleHitbox = new RectangleHitbox(x, y, 16, 16);
rectangleHitbox.TargetNothing();
rectangleHitbox.BindToInterface<ISolid>();
Collider.AddHitbox(rectangleHitbox);
var circleHitbox = new CircleHitbox(x, y, 32);
circleHitbox.BindToNothing();
circleHitbox.TargetInterface<IPlayer>();
Collider.AddHitbox(circleHitbox);
Collider.Add((IPlayer player) =>
{
// your code for player interaction here
});
Collider.OnSubstep = () =>
{
Collider.CheckAndRun<IPlayer>();
}
}
protected override void OnStep()
{
Collider.MoveAndCollide(VelX, VelY);
}
}With this configuration, only the CircleHitbox will be used when this Collider looks for collisions with IPlayer. Note that we had to tell the RectangleHitbox to not target anything - much like with bindings, by default all Hitboxes within a Collider will look for all interfaces added via Collider.Add() and checked in the Collider.OnSubstep.
As demonstrated in the previous (mostly complete) example, you must manually call Collider.MoveAndCollide(float velocityX, float velocityY) to move and run collision events. This is fine for most purposes, but sometimes you may want to run an additional check (such as to gather more information in a collision event). You can call a variety of Collider methods for this purpose, or you may run individual shape checks through the LOIC itself.