372 lines
13 KiB
Python
372 lines
13 KiB
Python
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()
|