Shared chat
Bouncing Ball Simulation
This is a shared TryAI chat. Sign in to start your own conversation.
Sign in to TryAIShared chat
This is a shared TryAI chat. Sign in to start your own conversation.
Sign in to TryAIOkay, this is a fun and moderately complex problem involving 2D physics and Pygame for visualization!
Here's a Python program using Pygame that demonstrates a ball bouncing inside a spinning hexagon, with gravity and friction (restitution).
How it Works:
Setup (Pygame, Constants):
GRAVITY: Constant downward acceleration.RESTITUTION: How much energy is kept after a bounce (1.0 = perfectly elastic, 0.0 = no bounce).FRICTION_COEFF: Reduces the tangential velocity component of the ball relative to the wall during a bounce. This makes the ball "roll" or "slide" less along the wall after impact.HEXAGON_ROTATION_SPEED: How fast the hexagon spins.Helper Functions:
rotate_point: Standard 2D point rotation.magnitude, normalize, dot_product): Essential for physics calculations. Pygame's Vector2 handles many of these.Ball Class:
__init__: Sets initial position, velocity (randomized slightly), radius, and color.update(dt):
vel.y.pos based on vel. dt (delta time) ensures frame-rate independent movement.draw(): Draws the ball as a circle.Hexagon Class:
__init__: Sets center, size (approximated by side_length which is really radius to vertices), color, and rotation speed._calculate_vertices():
angle, center, and side_length.edges (pairs of vertices) and normals (outward-pointing perpendicular vectors for each edge). These are vital for collision detection. The vertices are defined counter-clockwise (CCW).update(dt):
angle.draw(): Draws the hexagon polygon.get_edge_point_velocity(point_on_edge): This is key for realistic bounces off a moving surface. It calculates the linear velocity of a specific point on the hexagon's edge due to its rotation (v = omega * r, where omega is angular velocity and r is the vector from the center of rotation to the point).handle_collisions(ball, hexagon, dt): This is the core physics logic.
edge of the hexagon.closest_point_on_line segment from the ball's center to that edge.distance from the ball's center to this closest point.distance < ball.radius, a collision has occurred.normal of the collided edge by the penetration_depth. This prevents the ball from getting stuck.wall_point_velocity: The velocity of the hexagon's surface at the point of impact is calculated using hexagon.get_edge_point_velocity().relative_velocity = ball.vel - wall_point_velocity. All bounce calculations are done using this relative velocity.relative_velocity is decomposed into components normal and tangential to the collision surface.RESTITUTION. vn_scalar = relative_velocity.dot(normal) gives the magnitude of velocity along the normal. If it's positive, they are already moving apart, so we skip the bounce to avoid issues.(1 - FRICTION_COEFF) to simulate friction slowing down sliding/rolling motion.new_relative_velocity.wall_point_velocity is added back to the new_relative_velocity to get the ball's new absolute velocity in world space: ball.vel = new_relative_velocity + wall_point_velocity.Main Game Loop (main()):
Ball and Hexagon objects.dt = clock.tick(FPS) / (1000 / FPS): Calculates delta time, normalized so dt=1.0 when running at target FPS. This makes physics calculations smoother and independent of frame rate fluctuations.update() for the ball and hexagon.handle_collisions().screen.fill(BLACK)).draw() for the hexagon and ball.pygame.display.flip()).To Run This Code:
pip install pygamebouncing_hexagon.py).python bouncing_hexagon.pyYou should see a red ball bouncing inside a blue, rotating hexagon. Press 'R' to reset the ball's position and velocity.
You're right to point this out! The most common reason for a ball falling through a shape like this, especially if it happens quickly, is an issue in the collision response, specifically how the ball's position is corrected when a penetration is detected.
Looking at the original handle_collisions function, the logic for correction_normal has a subtle but critical flaw:
The Problem:
dist_vec is calculated as ball.pos - closest_point_on_line. This vector points from the closest point on the wall segment to the ball's center.normal is the outward-pointing normal of the hexagon edge.distance < ball.radius), its center is "inside" the wall.else branch (which is hit when dist_vec.dot(normal) <= 0, a common scenario for penetration), dist_vec.normalize() gives a unit vector pointing from the wall towards the ball's current (penetrating) center.dist_vec.normalize() by penetration_depth, you are effectively pushing it further along the direction it was already penetrating, or not directly outwards along the surface normal. This can cause it to pass through.The Fix:
The penetration should always be resolved by moving the ball outwards along the surface normal of the edge it collided with.
Here's the corrected handle_collisions function and the main function (only handle_collisions is significantly changed, but I'll provide the full context for easy copy-pasting).
Key Changes Made:
Corrected Penetration Resolution:
In handle_collisions:
This ensures the ball is pushed directly out of the wall along the edge's outward normal.
Slightly Adjusted Initial Ball Position:
In main():
The ball now starts a bit higher up, more towards the center of the hexagon, giving it more space before the first collision. This isn't strictly necessary if the collision logic is perfect, but it can help avoid complex initial interactions. The reset key 'R' also uses this position.
Small Epsilon in Collision Detection:
In handle_collisions:
This adds a tiny tolerance to prevent issues if distance becomes zero or extremely small, which can sometimes cause problems with normalization or division by zero in subsequent calculations (though Vector2.normalize() handles zero vectors). The elif handles the rare case where the ball center might be exactly on the closest_point_on_line.
Hexagon get_edge_point_velocity Clarification:
The get_edge_point_velocity method in Hexagon uses self.rotation_speed. This value is HEXAGON_ROTATION_SPEED (e.g., 0.01 radians per frame). The velocities vx and vy are therefore in pixels per frame (assuming dt=1 in the update cycle). This is consistent with how ball.vel is updated. No change was strictly needed here for the fall-through bug, but it's an area where dt scaling can be tricky if not handled consistently.
Safe dt calculation:
Added 1000.0 for float division and a check for dt == 0.
Try this corrected version. The ball should now bounce much more reliably off the hexagon walls.
import pygameimport mathimport random
# --- Constants ---SCREEN_WIDTH = 800SCREEN_HEIGHT = 600FPS = 60
# ColorsWHITE = (255, 255, 255)BLACK = (0, 0, 0)RED = (255, 0, 0)GREEN = (0, 255, 0)BLUE = (0, 0, 255)YELLOW = (255, 255, 0)CYAN = (0, 255, 255)MAGENTA = (255, 0, 255)
# Physics parametersGRAVITY = 0.5 # pixels/frame^2BALL_RADIUS = 15HEXAGON_SIDE_LENGTH = 200 # Approximate side length, hexagon size is based on thisHEXAGON_ROTATION_SPEED = 0.01 # radians per frameRESTITUTION = 0.8 # Coefficient of restitution (bounciness)FRICTION_COEFF = 0.05 # Friction against wall tangent, reduces tangential velocity on bounce
# --- Helper Functions ---def rotate_point(point, angle, center): """Rotates a point around a center.""" s = math.sin(angle) c = math.cos(angle) x, y = point cx, cy = center
# Translate point back to origin: x -= cx y -= cy
# Rotate point x_new = x * c - y * s y_new = x * s + y * c
# Translate point back: x_new += cx y_new += cy return (x_new, y_new)
def magnitude(v): return math.sqrt(v[0]**2 + v[1]**2)
def normalize(v): mag = magnitude(v) if mag == 0: return (0, 0) return (v[0] / mag, v[1] / mag)
def dot_product(v1, v2): return v1[0] * v2[0] + v1[1] * v2[1]
# --- Ball Class ---class Ball: def __init__(self, x, y, radius, color): self.pos = pygame.Vector2(x, y) self.vel = pygame.Vector2(random.uniform(-3, 3), random.uniform(-2, 0)) # Initial random velocity self.radius = radius self.color = color
def update(self, dt): # Apply gravity self.vel.y += GRAVITY * dt
# Update position self.pos += self.vel * dt
def draw(self, screen): pygame.draw.circle(screen, self.color, (int(self.pos.x), int(self.pos.y)), self.radius)
# --- Hexagon Class ---class Hexagon: def __init__(self, center_x, center_y, side_length, color, rotation_speed): self.center = pygame.Vector2(center_x, center_y) self.side_length = side_length # This is actually more like the radius to the vertices self.color = color self.angle = 0 # Initial angle self.rotation_speed = rotation_speed # Radians per frame self.vertices = [] # Current rotated vertices self.edges = [] # Tuples of (start_vertex, end_vertex) self.normals = [] # Outward normal for each edge self._calculate_vertices() # Initial calculation
def _calculate_vertices(self): self.vertices = [] for i in range(6): # Angle for each vertex of a regular hexagon angle_deg = 60 * i - 30 # -30 to make one side flat at the bottom initially angle_rad = math.radians(angle_deg) + self.angle # Add current rotation # Vertex position relative to (0,0) x = self.side_length * math.cos(angle_rad) y = self.side_length * math.sin(angle_rad) # Translate to hexagon center self.vertices.append(pygame.Vector2(self.center.x + x, self.center.y + y))
# Calculate edges and normals self.edges = [] self.normals = [] num_vertices = len(self.vertices) for i in range(num_vertices): p1 = self.vertices[i] p2 = self.vertices[(i + 1) % num_vertices] # Wrap around for the last edge self.edges.append((p1, p2))
edge_vec = p2 - p1 # Outward normal (for CCW vertices, (dy, -dx) is outward) # If vertices were CW, (-dy, dx) would be outward. # Our vertices are generated CCW (increasing angle_deg). normal = pygame.Vector2(edge_vec.y, -edge_vec.x).normalize() self.normals.append(normal)
def update(self, dt): self.angle += self.rotation_speed * dt self._calculate_vertices()
def draw(self, screen): pygame.draw.polygon(screen, self.color, self.vertices, 3) # 3 is line thickness
def get_edge_point_velocity(self, point_on_edge): """Calculates the velocity of a point on the hexagon's edge due to rotation.""" # Vector from center of hexagon to the point on edge r_vec = point_on_edge - self.center # Tangential velocity v = omega x r. In 2D, if r = (rx, ry), v = omega * (-ry, rx) # self.rotation_speed is omega (angular velocity) vx = -self.rotation_speed * r_vec.y # Note: dt is already in rotation_speed vy = self.rotation_speed * r_vec.x return pygame.Vector2(vx, vy)
# --- Collision Handling ---def handle_collisions(ball, hexagon, dt): for i, edge in enumerate(hexagon.edges): p1, p2 = edge normal = hexagon.normals[i]
# Vector from p1 to ball center p1_to_ball = ball.pos - p1 # Edge vector edge_vec = p2 - p1 edge_len_sq = edge_vec.length_squared()
if edge_len_sq == 0: # Should not happen for a valid hexagon continue
# Project p1_to_ball onto edge_vec to find closest point on the line # t is the projection parameter (0 if closest point is p1, 1 if p2) t = p1_to_ball.dot(edge_vec) / edge_len_sq closest_point_on_line = None if t < 0: closest_point_on_line = p1 elif t > 1: closest_point_on_line = p2 else: closest_point_on_line = p1 + t * edge_vec # Distance from ball center to closest point on line segment dist_vec = ball.pos - closest_point_on_line distance = dist_vec.length()
if distance < ball.radius: # --- Collision detected ---
# 1. Resolve penetration: Move ball out along the normal penetration_depth = ball.radius - distance # Ensure we move along the correct normal direction. # If dist_vec.dot(normal) is negative, normal is pointing towards ball, good. # Otherwise, flip normal (shouldn't happen with correct normals) if dist_vec.length_squared() > 0 and dist_vec.dot(normal) > 0: correction_normal = normal else: # Ball is already outside or normal is flipped correction_normal = dist_vec.normalize() if dist_vec.length_squared() > 0 else normal
ball.pos += correction_normal * penetration_depth
# 2. Calculate relative velocity # Velocity of the point on the hexagon wall where collision occurs # For simplicity, use closest_point_on_line as impact point wall_point_velocity = hexagon.get_edge_point_velocity(closest_point_on_line) relative_velocity = ball.vel - wall_point_velocity
# 3. Reflect velocity along the normal # vn = normal component of relative_velocity vn_scalar = relative_velocity.dot(normal)
# If vn_scalar > 0, objects are moving apart, no collision response needed # This check prevents "sticky" collisions if penetration isn't fully resolved if vn_scalar >= 0: continue
# Decompose relative velocity into normal and tangential components v_normal_comp_vec = vn_scalar * normal v_tangential_comp_vec = relative_velocity - v_normal_comp_vec # Apply restitution to normal component v_normal_comp_vec *= -RESTITUTION # Apply friction to tangential component v_tangential_comp_vec *= (1 - FRICTION_COEFF)
# New relative velocity new_relative_velocity = v_normal_comp_vec + v_tangential_comp_vec # New ball absolute velocity ball.vel = new_relative_velocity + wall_point_velocity
# --- Main Game Loop ---def main(): pygame.init() screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) pygame.display.set_caption("Bouncing Ball in Spinning Hexagon") clock = pygame.time.Clock()
ball = Ball(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 3, BALL_RADIUS, RED) hexagon = Hexagon(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2, HEXAGON_SIDE_LENGTH, BLUE, HEXAGON_ROTATION_SPEED)
running = True while running: dt_raw = clock.tick(FPS) # Milliseconds since last frame dt = dt_raw / (1000 / FPS) # Normalize dt to be 1.0 at target FPS, useful for physics scaling
for event in pygame.event.get(): if event.type == pygame.QUIT: running = False if event.type == pygame.KEYDOWN: if event.key == pygame.K_r: # Reset ball ball = Ball(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 3, BALL_RADIUS, RED)
# Updates ball.update(dt) hexagon.update(dt) handle_collisions(ball, hexagon, dt)
# Check if ball is way out of bounds (e.g., escaped due to high speed/glitch) # A simple reset mechanism. More robust would be to prevent escape. if not (-2*SCREEN_WIDTH < ball.pos.x < 3*SCREEN_WIDTH and \ -2*SCREEN_HEIGHT < ball.pos.y < 3*SCREEN_HEIGHT): print("Ball escaped! Resetting.") ball = Ball(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 3, BALL_RADIUS, RED)
# Drawing screen.fill(BLACK) hexagon.draw(screen) ball.draw(screen) pygame.display.flip()
pygame.quit()
if __name__ == "__main__": main() # ... # penetration_depth = ball.radius - distance # Original problematic logic: # if dist_vec.length_squared() > 0 and dist_vec.dot(normal) > 0: # correction_normal = normal # else: # This branch is often taken for typical penetration # # dist_vec points from wall to ball. normal points out from wall. # # So dist_vec.normalize() points INWARD relative to the wall's outside. # # This pushes the ball further into the wall or in the wrong direction. # correction_normal = dist_vec.normalize() if dist_vec.length_squared() > 0 else normal # ball.pos += correction_normal * penetration_depth # ...import pygameimport mathimport random
# --- Constants ---SCREEN_WIDTH = 800SCREEN_HEIGHT = 600FPS = 60
# ColorsWHITE = (255, 255, 255)BLACK = (0, 0, 0)RED = (255, 0, 0)GREEN = (0, 255, 0)BLUE = (0, 0, 255)YELLOW = (255, 255, 0)CYAN = (0, 255, 255)MAGENTA = (255, 0, 255)
# Physics parametersGRAVITY = 0.5 # pixels/frame^2BALL_RADIUS = 15HEXAGON_SIDE_LENGTH = 200 # Approximate radius to verticesHEXAGON_ROTATION_SPEED = 0.01 # radians per frameRESTITUTION = 0.8 # Coefficient of restitution (bounciness)FRICTION_COEFF = 0.05 # Friction against wall tangent
# --- Helper Functions ---def rotate_point(point, angle, center): s = math.sin(angle) c = math.cos(angle) x, y = point cx, cy = center x -= cx y -= cy x_new = x * c - y * s y_new = x * s + y * c x_new += cx y_new += cy return (x_new, y_new)
# (Vector helper functions like magnitude, normalize, dot_product are handled by pygame.Vector2)
# --- Ball Class ---class Ball: def __init__(self, x, y, radius, color): self.pos = pygame.Vector2(x, y) self.vel = pygame.Vector2(random.uniform(-3, 3), random.uniform(-2, 0)) self.radius = radius self.color = color
def update(self, dt): self.vel.y += GRAVITY * dt self.pos += self.vel * dt
def draw(self, screen): pygame.draw.circle(screen, self.color, (int(self.pos.x), int(self.pos.y)), self.radius)
# --- Hexagon Class ---class Hexagon: def __init__(self, center_x, center_y, side_length, color, rotation_speed): self.center = pygame.Vector2(center_x, center_y) self.side_length = side_length self.color = color self.angle = 0 self.rotation_speed = rotation_speed self.vertices = [] self.edges = [] self.normals = [] self._calculate_vertices()
def _calculate_vertices(self): self.vertices = [] for i in range(6): angle_deg = 60 * i - 30 # Pointy-down hexagon angle_rad = math.radians(angle_deg) + self.angle x = self.side_length * math.cos(angle_rad) y = self.side_length * math.sin(angle_rad) self.vertices.append(pygame.Vector2(self.center.x + x, self.center.y + y))
self.edges = [] self.normals = [] num_vertices = len(self.vertices) for i in range(num_vertices): p1 = self.vertices[i] p2 = self.vertices[(i + 1) % num_vertices] self.edges.append((p1, p2)) edge_vec = p2 - p1 if edge_vec.length_squared() > 0: # Avoid division by zero for zero-length edge normal = pygame.Vector2(edge_vec.y, -edge_vec.x).normalize() else: # Should not happen with a proper hexagon normal = pygame.Vector2(0,0) # Or handle error self.normals.append(normal)
def update(self, dt): self.angle += self.rotation_speed * dt self._calculate_vertices()
def draw(self, screen): if len(self.vertices) >= 3: # Ensure there are enough vertices to draw a polygon pygame.draw.polygon(screen, self.color, self.vertices, 3)
def get_edge_point_velocity(self, point_on_edge): r_vec = point_on_edge - self.center # Using per-frame rotation speed, as dt is applied globally or implicitly in how speed is defined vx = -self.rotation_speed * r_vec.y # Omega * dt is already self.rotation_speed * dt vy = self.rotation_speed * r_vec.x # So here it should just be omega * r_vec # Corrected: rotation_speed is rad/frame (assuming dt=1) # If dt is applied to rotation_speed elsewhere, this might be omega_per_second * dt * r_vec # However, given rotation_speed is small, this should be angular velocity in rad/frame # So velocity is (omega_rad_per_frame * r_vec_pixels) # Let's assume rotation_speed is angular velocity omega (rad/step) where step duration is dt # The current self.rotation_speed is effectively omega * dt_target_frame_time. # To get velocity in pixels/step: # ang_vel_per_step = self.rotation_speed (this is angle change per step) vx_step = - (self.rotation_speed) * r_vec.y # This is simplified, assuming rotation_speed is angular velocity for the step vy_step = (self.rotation_speed) * r_vec.x # This is a bit subtle. If self.rotation_speed is rad/sec, it should be # ang_vel = self.rotation_speed * dt (if dt is in seconds) # For now, the original way is angular velocity per *normalized frame* return pygame.Vector2(vx, vy)
# --- Collision Handling (Corrected) ---def handle_collisions(ball, hexagon, dt): for i, edge in enumerate(hexagon.edges): p1, p2 = edge normal = hexagon.normals[i]
if normal.length_squared() == 0: # Skip if normal is zero vector continue
p1_to_ball = ball.pos - p1 edge_vec = p2 - p1 edge_len_sq = edge_vec.length_squared()
if edge_len_sq == 0: continue
t = p1_to_ball.dot(edge_vec) / edge_len_sq closest_point_on_line = None if t < 0: closest_point_on_line = p1 elif t > 1: closest_point_on_line = p2 else: closest_point_on_line = p1 + t * edge_vec dist_vec = ball.pos - closest_point_on_line distance = dist_vec.length()
if distance < ball.radius and distance > 1e-5: # Add small epsilon to avoid issues if distance is zero # --- Collision detected ---
# 1. Resolve penetration: Move ball out along the normal penetration_depth = ball.radius - distance # ** THE CRITICAL FIX IS HERE: ** # Always push the ball out along the outward surface normal. ball.pos += normal * penetration_depth
# 2. Calculate relative velocity wall_point_velocity = hexagon.get_edge_point_velocity(closest_point_on_line) relative_velocity = ball.vel - wall_point_velocity
# 3. Reflect velocity along the normal vn_scalar = relative_velocity.dot(normal)
if vn_scalar >= 0: # Already moving away or parallel, no bounce response needed continue
v_normal_comp_vec = vn_scalar * normal v_tangential_comp_vec = relative_velocity - v_normal_comp_vec v_normal_comp_vec *= -RESTITUTION v_tangential_comp_vec *= (1 - FRICTION_COEFF)
new_relative_velocity = v_normal_comp_vec + v_tangential_comp_vec ball.vel = new_relative_velocity + wall_point_velocity elif distance < 1e-5 and ball.radius > 1e-5: # If ball center is ON the closest_point # This can happen if penetration resolution overcorrects or due to precision. # Push it out slightly along the normal. ball.pos += normal * (ball.radius * 0.1) # Push out by a small fraction of radius
# --- Main Game Loop ---def main(): pygame.init() screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) pygame.display.set_caption("Bouncing Ball in Spinning Hexagon") clock = pygame.time.Clock()
# Initial position for the ball, a bit higher to avoid immediate complex interactions. ball = Ball(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2 - HEXAGON_SIDE_LENGTH // 2, BALL_RADIUS, RED) hexagon = Hexagon(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2, HEXAGON_SIDE_LENGTH, BLUE, HEXAGON_ROTATION_SPEED)
running = True while running: dt_raw = clock.tick(FPS) dt = dt_raw / (1000.0 / FPS) # Ensure float division for dt if dt == 0: dt = 1/FPS # Avoid dt=0 if tick returns 0 ms
for event in pygame.event.get(): if event.type == pygame.QUIT: running = False if event.type == pygame.KEYDOWN: if event.key == pygame.K_r: # Reset ball ball = Ball(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2 - HEXAGON_SIDE_LENGTH // 2, BALL_RADIUS, RED)
ball.update(dt) hexagon.update(dt) handle_collisions(ball, hexagon, dt) # dt is not strictly needed by this version of handle_collisions
# Basic out-of-bounds check (can be improved) if not (-SCREEN_WIDTH < ball.pos.x < 2*SCREEN_WIDTH and \ -SCREEN_HEIGHT < ball.pos.y < 2*SCREEN_HEIGHT): print("Ball escaped or is too far out! Resetting.") ball = Ball(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2 - HEXAGON_SIDE_LENGTH // 2, BALL_RADIUS, RED)
screen.fill(BLACK) hexagon.draw(screen) ball.draw(screen) pygame.display.flip()
pygame.quit()
if __name__ == "__main__": main() # 1. Resolve penetration: Move ball out along the normal penetration_depth = ball.radius - distance # ** THE CRITICAL FIX IS HERE: ** # Always push the ball out along the outward surface normal. ball.pos += normal * penetration_depth ball = Ball(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2 - HEXAGON_SIDE_LENGTH // 2, BALL_RADIUS, RED)if distance < ball.radius and distance > 1e-5:# ...elif distance < 1e-5 and ball.radius > 1e-5: # If ball center is ON the closest_point ball.pos += normal * (ball.radius * 0.1)