import pygame import sys import random import math from pygame.math import Vector2 # ------------------------------- # Component System and Base Classes # ------------------------------- class Component: def __init__(self, owner): self.owner = owner def update(self, dt): pass # RigidBody provides linear and angular motion class RigidBody(Component): def __init__(self, owner, mass=1.0): super().__init__(owner) self.mass = mass self.inv_mass = 1.0 / mass if mass != 0 else 0 self.velocity = Vector2(0, 0) self.force = Vector2(0, 0) self.angle = 0.0 # in radians self.angular_velocity = 0.0 self.torque = 0.0 self.inertia = 1.0 # moment of inertia (set later) self.inv_inertia = 1.0 def update(self, dt): # --- Linear integration --- acceleration = self.force * self.inv_mass self.velocity += acceleration * dt self.owner.pos += self.velocity * dt self.force = Vector2(0, 0) # --- Angular integration --- angular_acc = self.torque * self.inv_inertia self.angular_velocity += angular_acc * dt self.angle += self.angular_velocity * dt self.torque = 0.0 def apply_force(self, force, point=None): self.force += force if point is not None: # Torque = r x force (scalar in 2D: r.x * force.y - r.y * force.x) r = point - self.owner.pos self.torque += r.cross(force) # ------------------------------- # Collision Components # ------------------------------- # Base CollisionComponent (extends Component) class CollisionComponent(Component): def __init__(self, owner): super().__init__(owner) # A draw method is provided for debugging outlines. def draw(self, surface): pass # CircleCollider extends CollisionComponent class CircleCollider(CollisionComponent): def __init__(self, owner, radius): super().__init__(owner) self.radius = radius def get_position(self): return self.owner.pos def draw(self, surface): pygame.draw.circle(surface, (255,255,255), (int(self.owner.pos.x), int(self.owner.pos.y)), self.radius, 1) # SquareCollider extends CollisionComponent class SquareCollider(CollisionComponent): def __init__(self, owner, width, height): super().__init__(owner) self.width = width self.height = height def get_corners(self): hw = self.width / 2 hh = self.height / 2 # Retrieve the current rotation (in radians) from the RigidBody if present. rb = self.owner.get_component(RigidBody) angle = rb.angle if rb else 0 # Define corners in local space. corners = [Vector2(-hw, -hh), Vector2(hw, -hh), Vector2(hw, hh), Vector2(-hw, hh)] # Pygame’s rotate() expects degrees. rotated = [corner.rotate(math.degrees(angle)) + self.owner.pos for corner in corners] return rotated def draw(self, surface): corners = self.get_corners() points = [(p.x, p.y) for p in corners] pygame.draw.polygon(surface, (255,255,255), points, 1) # PolygonCollider extends CollisionComponent class PolygonCollider(CollisionComponent): def __init__(self, owner, points): super().__init__(owner) # points is a list of Vector2 objects, defined in local space. self.points = points def get_transformed_points(self): rb = self.owner.get_component(RigidBody) angle = rb.angle if rb else 0 transformed = [p.rotate(math.degrees(angle)) + self.owner.pos for p in self.points] return transformed def draw(self, surface): points = self.get_transformed_points() pts = [(p.x, p.y) for p in points] pygame.draw.polygon(surface, (255,255,255), pts, 1) # ------------------------------- # GameObject: Entities in the Scene # ------------------------------- class GameObject: def __init__(self, pos): self.pos = Vector2(pos) self.components = [] def add_component(self, comp): self.components.append(comp) def get_component(self, comp_type): for comp in self.components: if isinstance(comp, comp_type): return comp return None def update(self, dt): for comp in self.components: comp.update(dt) def draw(self, surface): # Draw any collision shapes (for visualization) for comp in self.components: if isinstance(comp, CollisionComponent): comp.draw(surface) # ------------------------------- # Collision Detection Functions # ------------------------------- def circle_vs_circle(circleA, circleB): posA = circleA.get_position() posB = circleB.get_position() rA = circleA.radius rB = circleB.radius diff = posB - posA dist = diff.length() if dist < (rA + rB): penetration = (rA + rB) - dist normal = diff.normalize() if dist != 0 else Vector2(1, 0) return normal, penetration return None, None def circle_vs_square(circle, square): center = circle.get_position() # Obtain the square’s rotation angle (in radians) rb = square.owner.get_component(RigidBody) angle = rb.angle if rb else 0 # Transform the circle center into the square's local space. local_center = (center - square.owner.pos).rotate(-math.degrees(angle)) hw = square.width / 2 hh = square.height / 2 closest_x = max(-hw, min(local_center.x, hw)) closest_y = max(-hh, min(local_center.y, hh)) closest = Vector2(closest_x, closest_y) diff = local_center - closest dist = diff.length() if dist < circle.radius: penetration = circle.radius - dist local_normal = diff.normalize() if dist != 0 else Vector2(1, 0) # Rotate the normal back to world space. normal = local_normal.rotate(math.degrees(angle)) return normal, penetration return None, None def square_vs_square(sq1, sq2): corners1 = sq1.get_corners() corners2 = sq2.get_corners() axes = [] def get_axes(corners): axes = [] for i in range(len(corners)): edge = corners[(i+1) % len(corners)] - corners[i] if edge.length() == 0: continue normal = Vector2(-edge.y, edge.x).normalize() axes.append(normal) return axes axes.extend(get_axes(corners1)) axes.extend(get_axes(corners2)) min_overlap = float('inf') collision_normal = None for axis in axes: proj1 = [p.dot(axis) for p in corners1] proj2 = [p.dot(axis) for p in corners2] min1, max1 = min(proj1), max(proj1) min2, max2 = min(proj2), max(proj2) if max1 < min2 or max2 < min1: return None, None # Separating axis found; no collision. overlap = min(max1, max2) - max(min1, min2) if overlap < min_overlap: min_overlap = overlap collision_normal = axis # Ensure the normal points from sq1 to sq2. if (sq2.owner.pos - sq1.owner.pos).dot(collision_normal) < 0: collision_normal = -collision_normal return collision_normal, min_overlap # Universal collision check selects routine based on collider types. def check_collision(objA, objB): colA = objA.get_component(CollisionComponent) colB = objB.get_component(CollisionComponent) if colA is None or colB is None: return None, None # Circle vs Circle. if isinstance(colA, CircleCollider) and isinstance(colB, CircleCollider): return circle_vs_circle(colA, colB) # Circle vs Square. if isinstance(colA, CircleCollider) and isinstance(colB, SquareCollider): return circle_vs_square(colA, colB) if isinstance(colA, SquareCollider) and isinstance(colB, CircleCollider): normal, penetration = circle_vs_square(colB, colA) if normal is not None: return -normal, penetration # Square vs Square. if isinstance(colA, SquareCollider) and isinstance(colB, SquareCollider): return square_vs_square(colA, colB) # (PolygonCollider collisions can be implemented similarly using SAT.) return None, None # ------------------------------- # Collision Resolution # ------------------------------- def resolve_collision(objA, objB, normal, penetration): rbA = objA.get_component(RigidBody) rbB = objB.get_component(RigidBody) if rbA is None or rbB is None: return total_inv_mass = rbA.inv_mass + rbB.inv_mass if total_inv_mass == 0: return # Correct positions (simple penetration resolution). correction = normal * (penetration / total_inv_mass * 0.5) objA.pos -= correction * rbA.inv_mass objB.pos += correction * rbB.inv_mass # Relative velocity along collision normal. relative_vel = rbB.velocity - rbA.velocity vel_along_normal = relative_vel.dot(normal) if vel_along_normal > 0: return # They are separating. restitution = 0.5 # Bounce factor. impulse_scalar = -(1 + restitution) * vel_along_normal / total_inv_mass impulse = normal * impulse_scalar rbA.velocity -= impulse * rbA.inv_mass rbB.velocity += impulse * rbB.inv_mass # --- Angular response --- # As an approximation, we use the average of the object centers as the contact point. contact_point = (objA.pos + objB.pos) * 0.5 rA = contact_point - objA.pos rB = contact_point - objB.pos # In 2D, the scalar "cross" is computed with Vector2.cross(). rbA.torque -= rA.cross(impulse) rbB.torque += rB.cross(impulse) # ------------------------------- # Utility: Set the Moment of Inertia Based on the Collider Type # ------------------------------- def set_inertia(obj): rb = obj.get_component(RigidBody) if rb is None: return col = obj.get_component(CollisionComponent) if isinstance(col, CircleCollider): rb.inertia = 0.5 * rb.mass * (col.radius ** 2) elif isinstance(col, SquareCollider): rb.inertia = rb.mass * ((col.width ** 2 + col.height ** 2) / 12) else: rb.inertia = rb.mass # Default value. rb.inv_inertia = 1.0 / rb.inertia if rb.inertia != 0 else 0 # ------------------------------- # Object Factories # ------------------------------- def create_circle(pos, radius, mass): obj = GameObject(pos) rb = RigidBody(obj, mass) obj.add_component(rb) col = CircleCollider(obj, radius) obj.add_component(col) set_inertia(obj) return obj def create_square(pos, width, height, mass): obj = GameObject(pos) rb = RigidBody(obj, mass) obj.add_component(rb) col = SquareCollider(obj, width, height) obj.add_component(col) set_inertia(obj) return obj # ------------------------------- # Main Game Loop and Setup # ------------------------------- pygame.init() WIDTH, HEIGHT = 800, 600 screen = pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption("Physics Engine with Rotating Collisions") clock = pygame.time.Clock() # Create a list of objects. objects = [] # Create some circles. for _ in range(3): pos = (random.randint(100, 700), random.randint(100, 300)) circle = create_circle(pos, random.randint(15, 30), mass=1) # Give each circle an initial random velocity. circle.get_component(RigidBody).velocity = Vector2(random.uniform(-100, 100), random.uniform(-100, 100)) objects.append(circle) # Create some squares ("cubes"). for _ in range(3): pos = (random.randint(100, 700), random.randint(100, 300)) square = create_square(pos, random.randint(30, 50), random.randint(30, 50), mass=2) square.get_component(RigidBody).velocity = Vector2(random.uniform(-100, 100), random.uniform(-100, 100)) objects.append(square) # Gravity (pixels per second^2). GRAVITY = Vector2(0, 200) # Main loop. running = True while running: dt = clock.tick(60) / 1000.0 # Delta time in seconds. for event in pygame.event.get(): if event.type == pygame.QUIT: running = False # Apply gravity to each object. for obj in objects: rb = obj.get_component(RigidBody) if rb: rb.apply_force(GRAVITY * rb.mass) # Update all objects. for obj in objects: obj.update(dt) # Check collisions between all pairs. for i in range(len(objects)): for j in range(i + 1, len(objects)): normal, penetration = check_collision(objects[i], objects[j]) if normal is not None: resolve_collision(objects[i], objects[j], normal, penetration) # Rendering. screen.fill((30, 30, 30)) for obj in objects: obj.draw(screen) pygame.display.flip() pygame.quit() sys.exit()