From 55f20a950f05ecd8e5349924d17d4f995c19bac9 Mon Sep 17 00:00:00 2001 From: = <=> Date: Wed, 11 Mar 2026 12:14:45 -0400 Subject: [PATCH] overhaul to the collision system... should have maybe made more commits. --- .gitignore | 0 __pycache__/collider.cpython-311.pyc | Bin 0 -> 16753 bytes __pycache__/rigidbody.cpython-311.pyc | Bin 0 -> 5832 bytes __pycache__/transform.cpython-311.pyc | Bin 0 -> 2064 bytes collider.py | 206 ++++++++++++++++++++++++ main.py | 218 ++++++++------------------ requirements.txt | Bin 0 -> 32 bytes rigidbody.py | 76 +++++++++ sprite.py | 0 tools.py | 0 transform.py | 38 +++++ 11 files changed, 386 insertions(+), 152 deletions(-) create mode 100644 .gitignore create mode 100644 __pycache__/collider.cpython-311.pyc create mode 100644 __pycache__/rigidbody.cpython-311.pyc create mode 100644 __pycache__/transform.cpython-311.pyc create mode 100644 collider.py create mode 100644 requirements.txt create mode 100644 rigidbody.py create mode 100644 sprite.py create mode 100644 tools.py create mode 100644 transform.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/__pycache__/collider.cpython-311.pyc b/__pycache__/collider.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d5fb6b0079196b60d511676f1541567fdd1e9e3b GIT binary patch literal 16753 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 u)ju0-(~C6 z1o5h}!eQPY6aKKj zfDER7V+|RLN4tTt1aqh?jV`SWzB#r)|krh@y9wQ*Bhy$9~5UW?X=*pBR0t70u zFXFA`!eaUdMi}Ki9V#8$|5@}VT-f_9s1IEQTo_PZ)ySS-5HIxWQPn=B9|FsdF}n+7 sEMtsE=-ps;j!-3-o##{RIWCN)RSb@1$uo{U=LABMiU-Scn!=@j0ry6<(f|Me literal 0 HcmV?d00001 diff --git a/collider.py b/collider.py new file mode 100644 index 0000000..8c902d9 --- /dev/null +++ b/collider.py @@ -0,0 +1,206 @@ +from dataclasses import dataclass +from abc import ABC, abstractmethod +from typing import Generator + +import pygame as pg +from math import pi + +from transform import Transform + +@dataclass +class Face: + begin: pg.Vector2 + end: pg.Vector2 + + @property + def normal(self) -> pg.Vector2: + return (self.end-self.begin).rotate(-90).normalize() + +@dataclass +class ColliderContact: + __slots__ = ["points", "normal", "penetration"] + points: list[pg.Vector2] + normal: pg.Vector2 + penetration: float + +@dataclass(frozen=True) +class PolygonalHull: + _vertices: list[pg.Vector2] + + def get_vertex_at_index(self, key: int) -> pg.Vector2: + return self._vertices[key] + + def get_face_at_index(self, key: int) -> Face: + return Face(self._vertices[key], self._vertices[(key + 1) % len(self._vertices)]) + + def vertices(self) -> Generator[pg.Vector2, None, None]: + for v in self._vertices: + yield v + + def faces(self) -> Generator[Face, None, None]: + for i in range(len(self._vertices)): + yield self.get_face_at_index(i) + + def project(self, axis: pg.Vector2) -> tuple[float, float]: + projections = [v.dot(axis) for v in self._vertices] + return (min(projections), max(projections)) + + +class BaseCollider(ABC): + + @abstractmethod + def moment_of_inertia(self, mass: float) -> float: + pass + +class ConvexCollider(BaseCollider): + + @abstractmethod + def hull(self, transform: Transform) -> PolygonalHull: + pass + +@dataclass +class CircleCollider(BaseCollider): + + radius: float + + def moment_of_inertia(self, mass: float) -> float: + return 0.5 * self.radius ** 2 * mass + +@dataclass +class LineCollider(ConvexCollider): + + length: float + + def hull(self, transform: Transform) -> PolygonalHull: + return PolygonalHull([ + transform.global_position - pg.Vector2(self.length / 2.0, 0).rotate(transform.global_degrees) * transform.global_scale, + transform.global_position + pg.Vector2(self.length / 2.0, 0).rotate(transform.global_degrees) * transform.global_scale + ]) + + def moment_of_inertia(self, mass): + return 1.0 / 12.0 * mass * self.length**2 + + +@dataclass +class RectCollider(ConvexCollider): + + dimensions: tuple[float, float] + + @property + def width(self): + return self.dimensions[0] + + @property + def height(self): + return self.dimensions[1] + + def hull(self, transform: Transform) -> PolygonalHull: + return PolygonalHull([ + transform.global_position - pg.Vector2(self.width / 2.0, self.height / 2.0).rotate(transform.global_degrees) * transform.global_scale, + transform.global_position - pg.Vector2(-self.width / 2.0, self.height / 2.0).rotate(transform.global_degrees) * transform.global_scale, + transform.global_position - pg.Vector2(-self.width / 2.0, -self.height / 2.0).rotate(transform.global_degrees) * transform.global_scale, + transform.global_position - pg.Vector2(self.width / 2.0, -self.height / 2.0).rotate(transform.global_degrees) * transform.global_scale + ]) + + def moment_of_inertia(self, mass: float) -> float: + return (1.0 / 12.0) * mass * (self.width ** 2 + self.height ** 2) + +def _interval_overlap(a: tuple[float, float], b: tuple[float, float]) -> float | None: + if a[0] <= b[1] and b[0] <= a[1]: + return min(a[1], b[1]) - max(a[0], b[0]) + return None + +def _collide_circle_circle(a: CircleCollider, b: CircleCollider, a_transform: Transform, b_transform: Transform) -> ColliderContact | None: + delta = a_transform.global_position - b_transform.global_position + dist = delta.length() + radii = b.radius + b.radius + if dist >= radii or dist == 0: + return None + normal = delta.normalize() + return ColliderContact( + point=a_transform.global_position + normal * b.radius, + normal=normal, + penetration=radii - dist, + ) + +def _collide_convex_circle(a: ConvexCollider, b: CircleCollider, a_transform: Transform, b_transform: Transform) -> ColliderContact | None: + hull = a.hull(a_transform) + normals = [face.normal for face in hull.faces()] + circle_normal = min([(b_transform.global_position - v) for v in hull.vertices()], key=lambda v: v.length()).normalize() + normals.append(circle_normal) + collision_normal: pg.Vector2 | None = None + lowest_pen = float('inf') + for normal in normals: + center_proj = normal.dot(b_transform.global_position) + circle_interval = (center_proj - b.radius, center_proj + b.radius) + convex_interval = hull.project(normal) + penetration = _interval_overlap(circle_interval, convex_interval) + if penetration is None: + return None + if penetration < lowest_pen: + lowest_pen = penetration + collision_normal = normal + return ColliderContact( + points=[b_transform.global_position - b.radius*normal], + normal=collision_normal, + penetration=lowest_pen + ) + +def _collide_convex_convex(a: ConvexCollider, b: ConvexCollider, a_transform: Transform, b_transform: Transform) -> ColliderContact | None: + #SAT + hull_a = a.hull(a_transform) + hull_b = b.hull(b_transform) + normals = [*[face.normal for face in hull_a.faces()],*[face.normal for face in hull_b.faces()]] + collision_normal: pg.Vector2 | None = None + lowest_pen = float('inf') + for normal in normals: + a_interval = hull_a.project(normal) + b_interval = hull_b.project(normal) + penetration = _interval_overlap(a_interval, b_interval) + if penetration is None: + return None + if penetration < lowest_pen and (a_transform.position - b_transform.position).dot(normal) > 0: + lowest_pen = penetration + collision_normal = normal + + + #sutherland hodgman clipping + ref_face = max(hull_b.faces(), key=lambda f: f.normal.dot(collision_normal)) + incident_face = min(hull_a.faces(), key=lambda f: f.normal.dot(collision_normal)) + left_normal = (ref_face.begin - ref_face.end).normalize() + right_normal = (ref_face.end - ref_face.begin).normalize() + + contact_manifold = [incident_face.begin, incident_face.end] + + def clip(normal: pg.Vector2, ref_point: pg.Vector2) -> None: + d1 = (contact_manifold[0] - ref_point).dot(normal) + d2 = (contact_manifold[1] - ref_point).dot(normal) + if d1 > 0 and d2 > 0: + raise Exception("CLIPPING ERROR") + if d1 > 0: + contact_manifold[0] = contact_manifold[0] + (d1 / (d1 - d2)) * (contact_manifold[1] - contact_manifold[0]) + if d2 > 0: + contact_manifold[1] = contact_manifold[1] + (d2 / (d2 - d1)) * (contact_manifold[0] - contact_manifold[1]) + + clip(right_normal, ref_face.end) + clip(left_normal, ref_face.begin) + clip(ref_face.normal, ref_face.begin) + + return ColliderContact( + points=contact_manifold, + normal=collision_normal, + penetration=lowest_pen + ) + +def intersect(a: BaseCollider, b: BaseCollider, a_transform: Transform, b_transform: Transform) -> ColliderContact | None: + if isinstance(a, ConvexCollider) and isinstance(b, ConvexCollider): + return _collide_convex_convex(a,b,a_transform,b_transform) + if isinstance(a, ConvexCollider) and isinstance(b, CircleCollider): + return _collide_convex_circle(a, b, a_transform, b_transform) + if isinstance(a, CircleCollider) and isinstance(b, ConvexCollider): + collision = _collide_convex_circle(b,a,b_transform,a_transform) + if collision: collision.normal *= -1 + return collision + if isinstance(a, CircleCollider) and isinstance(b, CircleCollider): + return _collide_circle_circle(a,b,a_transform,b_transform) + raise NotImplementedError(f"No collision defined between collider types {type(a)} and {type(b)}") \ No newline at end of file diff --git a/main.py b/main.py index 49238fd..a98bcc3 100644 --- a/main.py +++ b/main.py @@ -1,176 +1,90 @@ import pygame as pg -from abc import ABC, abstractmethod -from dataclasses import dataclass -from itertools import combinations -from collections import namedtuple -from random import random +from math import pi +from rigidbody import * +from collider import LineCollider, CircleCollider -GLOBAL_GRAVITY=250 -DIMENSIONS = namedtuple("Dimensions", ["WIDTH", "HEIGHT"])(500,500) -@dataclass -class ColliderContact: - __slots__ = ["point", "normal", "penetration"] - point: pg.Vector2 - normal: pg.Vector2 - penetration: float +class Ball: -@dataclass -class RigidBody: - position: pg.Vector2 - collider: "BaseCollider" - velocity: pg.Vector2 - mass: float = 1.0 - restitution: float = 0.5 - - @property - def inv_mass(self): - return 0.0 if self.mass == 0 else 1.0 / self.mass - -class BaseCollider(ABC): - - def collide(self, other: "BaseCollider", this_position: pg.Vector2, other_position: pg.Vector2) -> ColliderContact | None: - if isinstance(other, CircleCollider): - return self.collide_circle(other, this_position, other_position) - - - @abstractmethod - def collide_circle(self, other: "CircleCollider", this_position: pg.Vector2, other_position: pg.Vector2) -> ColliderContact | None: - pass - -class CircleCollider(BaseCollider): - - def __init__(self, radius: float): + def __init__(self, transform: Transform, radius: float): + self.transform = transform self.radius = radius - - def collide_circle(self, other: "CircleCollider", this_position: pg.Vector2, other_position: pg.Vector2) -> ColliderContact | None: - delta = this_position - other_position - dist = delta.length() - radii = self.radius + other.radius - if dist >= radii or dist == 0: - return None - normal = delta.normalize() - return ColliderContact( - point=other_position + normal * other.radius, - normal=normal, - penetration=radii - dist, - ) - -class PhysicsSystem: - - def __init__(self): - self.bodies: list[RigidBody] = [] - self.gravity = pg.Vector2(0,GLOBAL_GRAVITY) - - def add_body(self, body: RigidBody) -> None: - self.bodies.append(body) - - def update(self, dt: float): - g = self.gravity * dt - for body in self.bodies: - body.velocity += g - body.position += dt * body.velocity - self.resolve_bounds(body) - - for a, b in combinations(self.bodies, 2): - if collision := a.collider.collide(b.collider, a.position, b.position): - self.resolve_collision(a, b, collision) - - def resolve_collision(self, a: RigidBody, b: RigidBody, collision: ColliderContact) -> None: - SLACK=0.4 - correction = collision.penetration / (a.inv_mass + b.inv_mass) * SLACK * collision.normal - a.position += correction - b.position -= correction - - v_rel = a.velocity - b.velocity - restitution = a.restitution * b.restitution - impulse = (-(1 + restitution) * (collision.normal.dot(v_rel))) / (a.inv_mass + b.inv_mass) - a.velocity += collision.normal*impulse*a.inv_mass - b.velocity -= collision.normal*impulse*b.inv_mass - - def resolve_bounds(self, body: RigidBody) -> None: - r = body.collider.radius - - if body.position.x - r < 0: - body.position.x = r - body.velocity.x = abs(body.velocity.x) * body.restitution - elif body.position.x + r > DIMENSIONS.WIDTH: - body.position.x = DIMENSIONS.WIDTH - r - body.velocity.x = -abs(body.velocity.x) * body.restitution - - if body.position.y - r < 0: - body.position.y = r - body.velocity.y = abs(body.velocity.y) * body.restitution - elif body.position.y + r > DIMENSIONS.HEIGHT: - body.position.y = DIMENSIONS.HEIGHT - r - body.velocity.y = -abs(body.velocity.y) * body.restitution - -class Circle: - - def __init__(self, radius: float, body: RigidBody, sprite: pg.Surface): - self.radius = radius - self.body = body - self.sprite = sprite + self.surface = pg.Surface((2*self.radius, 2*self.radius), pg.SRCALPHA) + self.surface.fill((255,255,255,0)) + pg.draw.circle( + self.surface, + color=(0,255,0,255), + center=(self.radius,self.radius), + radius=self.radius + ) def draw(self, screen: pg.Surface): - screen.blit( - self.sprite, - ( - self.body.position.x - self.radius, - self.body.position.y - self.radius - ) - ) - -class World: - - def __init__(self, screen: pg.Surface): - self.physics = PhysicsSystem() - self.screen = screen - self.entities = [] - - def update(self, dt) -> None: - self.physics.update(dt) - def draw(self) -> None: - for entity in self.entities: - entity.draw(self.screen) + surface = pg.transform.rotate(self.surface, self.transform.global_degrees) + screen.blit(surface, self.transform.global_position - pg.Vector2(self.radius, self.radius)) - def spawn_circle(self, radius: float, position: pg.Vector2, velocity: pg.Vector2 | None = None, restitution:float = 0.5, mass: float = 1.0) -> None: - if velocity is None: - velocity = pg.Vector2(0,0) - collider = CircleCollider(radius) - body = RigidBody(position=position, collider=collider, velocity=velocity, restitution=restitution,mass=mass) - self.physics.add_body(body) - sprite = pg.Surface((2*radius, 2*radius),pg.SRCALPHA) - sprite.fill((255,255,255,0)) - pg.draw.circle( - sprite, - color=(0,255,0,255), - center=(radius, radius), - radius=radius +class Square: + + def __init__(self, transform: Transform, side: float, color=(255,0,0,255)): + self.transform = transform + self.side = side + self.surface = pg.Surface((side, side), pg.SRCALPHA) + self.surface.fill((255,255,255,0)) + pg.draw.rect( + self.surface, + color=color, + rect=pg.Rect(0, 0, self.side, self.side) ) - self.entities.append(Circle(radius,body,sprite)) + + def draw(self, screen: pg.Surface): + + surface = pg.transform.rotate(self.surface, self.transform.global_degrees) + screen.blit(surface, self.transform.global_position - pg.Vector2(self.side / 2.0, self.side / 2.0)) def main(): running=True pg.init() clock=pg.time.Clock() - screen = pg.display.set_mode(size=DIMENSIONS) - world=World(screen) + screen = pg.display.set_mode((500,500)) + physics = PhysicsSystem() + + ball_transform = Transform(position=pg.Vector2(250,250), rotation=pi/8.0) + square_transform = Transform(position=pg.Vector2(250, 100)) + + ball = Square(ball_transform, 20) + + ball2 = Square(square_transform, 20, color=(0,255,0,255)) + + physics.add_body( + RigidBody( + ball_transform, + RectCollider((20,20)), + velocity=pg.Vector2(0,-400), + restitution=1.0, + ) + ) + + physics.add_body( + RigidBody( + square_transform, + RectCollider((20,20)), + pg.Vector2(0,0), + restitution=1.0 + ) + ) + while running: + dt = clock.tick(144) / 1000 screen.fill((0,0,0,0)) - world.update(dt) - world.draw() + + physics.update(dt) + ball.draw(screen) + ball2.draw(screen) pg.display.flip() for event in pg.event.get(): - if event.type == pg.MOUSEBUTTONDOWN: - world.spawn_circle( - 10, - pg.Vector2(pg.mouse.get_pos()), - restitution=random() - ) + if event.type == pg.QUIT: running = False diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..39051cd102c7e90baaf7c6eac29b0a91ddc7e115 GIT binary patch literal 32 kcmezWuYjSFA)O(SA(tVQ!4?RO81xv-fY^|Mmw}4`0FUMd2LJ#7 literal 0 HcmV?d00001 diff --git a/rigidbody.py b/rigidbody.py new file mode 100644 index 0000000..46ce3f7 --- /dev/null +++ b/rigidbody.py @@ -0,0 +1,76 @@ +from dataclasses import dataclass +from itertools import combinations + +import pygame as pg + +from collider import * +from transform import Transform + +@dataclass +class RigidBody: + transform: Transform + collider: BaseCollider + velocity: pg.Vector2 + angular_velocity: float = 0 + mass: float = 1.0 + restitution: float = 0.5 + + @property + def inv_mass(self) -> float: + return 0.0 if self.mass == 0.0 else 1/self.mass + + @property + def moment_of_inertia(self) -> float: + return 0.0 if self.mass == 0.0 else self.collider.moment_of_inertia(self.mass) + + @property + def inv_moment_of_inertia(self) -> float: + return 0.0 if self.mass == 0.0 else 1/self.moment_of_inertia + +class PhysicsSystem: + + def __init__(self, gravity: int = 250): + self.bodies: list[RigidBody] = [] + self.gravity = pg.Vector2(0,gravity) + + def add_body(self, body: RigidBody) -> None: + self.bodies.append(body) + + def update(self, dt: float): + g = self.gravity * dt + for body in self.bodies: + if body.mass != 0.0: + body.velocity += g + body.transform.position += dt * body.velocity + body.transform.rotation += dt * body.angular_velocity + + for a, b in combinations(self.bodies, 2): + if collision := intersect(a.collider, b.collider, a.transform, b.transform): + self.resolve_collision(a, b, collision) + + def resolve_collision(self, a: RigidBody, b: RigidBody, collision: ColliderContact) -> None: + SLACK=9000 + correction = collision.penetration / (a.inv_mass + b.inv_mass) * SLACK * collision.normal + + if a.mass != 0.0: + a.transform.position += correction + if b.mass != 0.0: + b.transform.position -= correction + + for point in collision.points: + r_a = point - a.transform.position + r_b = point - b.transform.position + + w_cross_r_a = pg.Vector2(-r_a.y * a.angular_velocity, r_a.x * a.angular_velocity) #omega is not encoded as a vector, so we pretend it is + w_cross_r_b = pg.Vector2(-r_b.y * b.angular_velocity, r_b.x * b.angular_velocity) + v_rel = a.velocity + w_cross_r_a - b.velocity - w_cross_r_b + restitution = a.restitution * b.restitution + impulse = -(1+restitution)*(v_rel.dot(collision.normal))\ + / (a.inv_mass + b.inv_mass + (r_a.cross(collision.normal)**2 * a.inv_moment_of_inertia)\ + + (r_b.cross(collision.normal)**2 * b.inv_moment_of_inertia)) / len(collision.points) + a.velocity += collision.normal*impulse*a.inv_mass + b.velocity -= collision.normal*impulse*b.inv_mass + a.angular_velocity += (r_a.cross(impulse*collision.normal)) * a.inv_moment_of_inertia + b.angular_velocity -= (r_b.cross(impulse*collision.normal)) * b.inv_moment_of_inertia + + diff --git a/sprite.py b/sprite.py new file mode 100644 index 0000000..e69de29 diff --git a/tools.py b/tools.py new file mode 100644 index 0000000..e69de29 diff --git a/transform.py b/transform.py new file mode 100644 index 0000000..b7de6b2 --- /dev/null +++ b/transform.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass +from math import pi + +import pygame as pg + +@dataclass +class Transform: + + position: pg.Vector2 + rotation: pg.Vector2 = 0.0 + scale: float = 1.0 + parent: 'Transform' = None + + @property + def global_position(self): + if self.parent is None: + return self.position + return self.position + self.parent.global_position + + @property + def global_rotation(self): + if self.parent is None: + return self.rotation + return self.position + self.parent.global_rotation + + @property + def global_scale(self): + if self.parent is None: + return self.scale + return self.scale * self.parent.global_scale + + @property + def global_degrees(self): + return self.global_rotation * (180.0 / pi) + + @property + def degrees(self): + return self.rotation * (180.0 / pi) \ No newline at end of file