Source code for rubato.structure.gameobject.physics.engine

"""Utility methods for colliding hitbox components."""
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
import math

from . import RigidBody, Circle, Polygon, Rectangle
from .... import Math, Vector, InitError

if TYPE_CHECKING:
    from . import Hitbox


# THIS IS A STATIC CLASS
class _Engine:
    """
    rubato's physics engine.
    Handles overlap tests for Hitboxes and resolves Rigidbody collisions.
    """

    def __init__(self) -> None:
        raise InitError(self)

    @staticmethod
    def resolve(col: Manifold):
        """
        Resolve the collision between two rigidbodies.

        Args:
            col: The collision information.
        """
        # INITIALIZATION STEP
        rb_a: RigidBody | None = col.shape_a.gameobj.get(RigidBody) if (RigidBody in col.shape_a.gameobj) else None
        rb_b: RigidBody | None = col.shape_b.gameobj.get(RigidBody) if (RigidBody in col.shape_b.gameobj) else None

        if not rb_a and not rb_b:
            return

        # calculate restitution
        e = max(rb_a.bounciness if rb_a else 0, rb_b.bounciness if rb_b else 0)

        # calculate friction coefficient
        if not rb_a:
            rv = rb_b.velocity  # type: ignore
            mu = rb_b.friction * rb_b.friction  # type: ignore
        elif not rb_b:
            rv = -rb_a.velocity
            mu = rb_a.friction * rb_a.friction
        else:
            rv = rb_b.velocity - rb_a.velocity
            mu = (rb_a.friction * rb_a.friction + rb_b.friction * rb_b.friction) / 2

        # find inverse masses
        inv_mass_a: float = rb_a.inv_mass if rb_a else 0
        inv_mass_b: float = rb_b.inv_mass if rb_b else 0

        # handle infinite mass cases
        if inv_mass_a == inv_mass_b == 0:
            if not rb_a:
                inv_mass_b = 1
            elif not rb_b:
                inv_mass_a = 1
            else:
                inv_mass_a, inv_mass_b = 1, 1

        col.normal *= -1

        # RESOLUTION STEP
        contact_vel = rv.dot(col.normal)

        inv_inert = 1 / (inv_mass_a + inv_mass_b)

        j = -(1 + e) * contact_vel * inv_inert

        impulse = col.normal * j

        t = rv - col.normal * rv.dot(col.normal)
        t.normalized(t)

        jt = -rv.dot(t) * inv_inert

        if abs(jt) < j * mu:
            t_impulse = t * jt
        else:
            t_impulse = -mu * t * j

        correction = max(col.penetration - 0.01, 0) * col.normal

        # Corrections
        if rb_a and not rb_a.static:
            rb_a.velocity -= impulse * inv_mass_a
            rb_a.velocity -= t_impulse * inv_mass_a
            col.shape_a.gameobj.pos -= correction * rb_a.pos_correction

        if rb_b and not rb_b.static:
            rb_b.velocity += impulse * inv_mass_b
            rb_b.velocity += t_impulse * inv_mass_b
            col.shape_b.gameobj.pos += correction * rb_b.pos_correction

    @staticmethod
    def overlap(hitbox_a: Hitbox, hitbox_b: Hitbox) -> Optional[Manifold]:
        """
        Determines if there is overlap between two hitboxes.
        Returns a Manifold manifold if a collision occurs but does not resolve.
        Note that this is only implemented for native rubato hitbox types (Rectangle, Polygon, Circle).

        Args:
            hitbox_a: The first hitbox to collide with.
            hitbox_b: The second hitbox to collide with.

        Returns:
            Returns a collision info object if overlap is detected or None if no collision is detected.
        """
        if not isinstance(hitbox_a,
                          Rectangle | Polygon | Circle) or not isinstance(hitbox_b, Rectangle | Polygon | Circle):
            raise TypeError("Engine.overlap() only supports Rectangle, Polygon, and Circle objects.")

        if isinstance(hitbox_a, Circle):
            if isinstance(hitbox_b, Circle):
                return _Engine._circle_circle_test(hitbox_a, hitbox_b)

            return _Engine._circle_polygon_test(hitbox_a, hitbox_b)

        if isinstance(hitbox_b, Circle):
            r = _Engine._circle_polygon_test(hitbox_b, hitbox_a)
            return None if r is None else r._flip()

        return _Engine._polygon_polygon_test(hitbox_a, hitbox_b)

    @staticmethod
    def collide(hitbox_a: Hitbox, hitbox_b: Hitbox) -> Optional[Manifold]:
        """
        Collides two hitboxes (if they overlap), calling their callbacks if they exist.
        Resolves the collision using Rigidbody impulse resolution if applicable.
        Note that this is only implemented for native rubato hitbox types (Rectangle, Polygon, Circle).

        Args:
            hitbox_a: The first hitbox to collide with.
            hitbox_b: The second hitbox to collide with.

        Returns:
            Returns a collision info object if a collision is detected or None if no collision is detected.
        """
        if not hitbox_a.should_collide(hitbox_a, hitbox_b) or not hitbox_b.should_collide(hitbox_b, hitbox_a):
            return

        col = _Engine.overlap(hitbox_a, hitbox_b)
        if col is None:
            if hitbox_b in hitbox_a.colliding:
                mani = Manifold(hitbox_a, hitbox_b)
                hitbox_a.colliding.remove(hitbox_b)
                hitbox_a.on_exit(mani)

            if hitbox_a in hitbox_b.colliding:
                mani = Manifold(hitbox_b, hitbox_a)
                hitbox_b.colliding.remove(hitbox_a)
                hitbox_b.on_exit(mani)

            return

        loc = col._flip()

        if hitbox_b not in hitbox_a.colliding:
            hitbox_a.colliding.add(hitbox_b)
            hitbox_a.on_enter(col)

        if hitbox_a not in hitbox_b.colliding:
            hitbox_b.colliding.add(hitbox_a)
            hitbox_b.on_enter(loc)

        if not (hitbox_a.trigger or hitbox_b.trigger):
            _Engine.resolve(col)

        hitbox_a.on_collide(col)
        hitbox_b.on_collide(loc)

    @staticmethod
    def _circle_circle_test(circle_a: Circle, circle_b: Circle) -> Optional[Manifold]:
        """Checks for overlap between two circles"""
        a_rad = circle_a.true_radius()
        b_rad = circle_b.true_radius()
        a_pos = circle_a.true_pos()
        b_pos = circle_b.true_pos()

        t_rad = a_rad + b_rad
        d_x, d_y = a_pos.x - b_pos.x, a_pos.y - b_pos.y
        dist = d_x * d_x + d_y * d_y

        if dist > t_rad * t_rad:
            return

        dist = math.sqrt(dist)

        if dist == 0:
            pen = a_rad
            norm = Vector(1, 0)
        else:
            pen = t_rad - dist
            norm = Vector(d_x / dist, d_y / dist)

        return Manifold(circle_a, circle_b, pen, norm)

    @staticmethod
    def _circle_polygon_test(circle: Circle, polygon: Polygon | Rectangle) -> Optional[Manifold]:
        """Checks for overlap between a circle and a polygon"""
        verts = polygon.offset_verts()
        circle_rad = circle.true_radius()
        circle_pos = circle.true_pos()
        poly_pos = polygon.true_pos()
        poly_rot = polygon.gameobj.true_rotation()

        center = (circle_pos - poly_pos).rotate(-poly_rot)

        separation = -Math.INF
        face_normal = 0

        for i in range(len(verts)):
            s = _Engine._get_normal(verts, i).dot(center - verts[i])

            if s > circle_rad:
                return

            if s > separation:
                separation = s
                face_normal = i

        if separation <= 0:
            norm = _Engine._get_normal(verts, face_normal).rotate(poly_rot)
            return Manifold(circle, polygon, circle_rad, norm)

        v1, v2 = verts[face_normal], verts[(face_normal + 1) % len(verts)]

        dot_1 = (center - v1).dot(v2 - v1)
        dot_2 = (center - v2).dot(v1 - v2)
        pen = circle_rad - separation

        if dot_1 <= 0:
            offs = center - v1
            if offs.mag_sq > circle_rad * circle_rad:
                return

            return Manifold(circle, polygon, pen, offs.rotate(poly_rot).normalized())
        elif dot_2 <= 0:
            offs = center - v2
            if offs.mag_sq > circle_rad * circle_rad:
                return

            return Manifold(circle, polygon, pen, offs.rotate(poly_rot).normalized())
        else:
            norm = _Engine._get_normal(verts, face_normal)
            if norm.dot(center - v1) > circle_rad:
                return

            return Manifold(circle, polygon, pen, norm.rotate(poly_rot))

    @staticmethod
    def _polygon_polygon_test(shape_a: Polygon | Rectangle, shape_b: Polygon | Rectangle) -> Optional[Manifold]:
        """Checks for overlap between two polygons"""
        a_verts = shape_a.offset_verts()
        b_verts = shape_b.offset_verts()

        pen_a, face_a = _Engine._axis_least_penetration(shape_a, shape_b, a_verts, b_verts)
        if pen_a is None or face_a is None:
            return

        pen_b, face_b = _Engine._axis_least_penetration(shape_b, shape_a, b_verts, a_verts)
        if pen_b is None or face_b is None:
            return

        if pen_b < pen_a:
            man = Manifold(shape_a, shape_b, abs(pen_a))
            rot = shape_a.gameobj.true_rotation()
            pos = shape_a.gameobj.true_pos()

            v1 = a_verts[face_a].rotate(rot) + pos
            v2 = a_verts[(face_a + 1) % len(a_verts)].rotate(rot) + pos

            side_plane_normal = (v2 - v1).normalized()
            man.normal = side_plane_normal.perpendicular() * Math.sign(pen_a)
        else:
            man = Manifold(shape_a, shape_b, abs(pen_b))
            rot = shape_b.gameobj.true_rotation()
            pos = shape_b.gameobj.true_pos()

            v1 = b_verts[face_b].rotate(rot) + pos
            v2 = b_verts[(face_b + 1) % len(b_verts)].rotate(rot) + pos

            side_plane_normal = (v2 - v1).normalized()
            man.normal = side_plane_normal.perpendicular() * -Math.sign(pen_b)

        return man

    @staticmethod
    def _axis_least_penetration(
        a: Polygon | Rectangle, b: Polygon | Rectangle, a_verts: list[Vector], b_verts: list[Vector]
    ) -> tuple[float, int] | tuple[None, None]:
        """Finds the axis of least penetration between two possibly colliding polygons."""
        best_dist = -Math.INF
        best_ind = 0
        a_rot = a.gameobj.true_rotation()
        b_rot = b.gameobj.true_rotation()

        for i in range(len(a_verts)):
            n = _Engine._get_normal(a_verts, i).rotate(a_rot).rotate(-b_rot)
            s = _Engine._get_support(b_verts, -n)
            v = (a_verts[i].rotate(a_rot) + a.gameobj.true_pos() - b.gameobj.true_pos()).rotate(-b_rot)
            d = n.dot(s - v)

            if d > best_dist:
                best_dist = d
                best_ind = i
                if d >= 0:
                    return None, None

        return best_dist, best_ind

    @staticmethod
    def _get_support(verts: list[Vector], direction: Vector) -> Vector | None:
        """Gets the furthest support vertex in a given direction."""
        best_proj = -Math.INF
        best_vert = None

        for v in verts:
            projection = v.dot(direction)

            if projection > best_proj:
                best_vert = v
                best_proj = projection

        return best_vert

    @staticmethod
    def _get_normal(verts: list[Vector], index: int) -> Vector:
        """Finds a vector perpendicular to a side"""
        face = (verts[(index + 1) % len(verts)] - verts[index]).perpendicular()
        face.magnitude = 1
        return face


[docs]class Manifold: """ A class that represents information returned in collision callbacks. Args: shape_a: The first shape involved in the collision (the reference shape). shape_b: The second shape involved in the collision (the incident shape). penetration: The amount of penetration between the two shapes. normal: The normal of the collision. """ def __init__( self, shape_a: Hitbox, shape_b: Hitbox, penetration: float = 0, normal: Vector = Vector(), ): self.shape_a: Hitbox = shape_a """The reference shape.""" self.shape_b: Hitbox = shape_b """The incident (colliding) shape.""" self.penetration: float = penetration """The amount by which the colliders are intersecting.""" self.normal: Vector = normal """The direction that would most quickly separate the two colliders.""" def __repr__(self) -> str: return ( f"Manifold(shape_a={self.shape_a}, shape_b={self.shape_b}, penetration={self.penetration}, " f"normal={self.normal})" ) def _flip(self) -> Manifold: """ Flips the reference shape in a collision manifold and inverts the normal vector. Returns: The new manifold """ return Manifold(self.shape_b, self.shape_a, self.penetration, -self.normal)