import pygame, sys, random, math pygame.init() screen = pygame.display.set_mode((800,600)) pygame.display.set_caption("Realistic Rotational Physics Derived from Collisions") clock = pygame.time.Clock() font = pygame.font.SysFont(None, 20) # Physics constants GRAVITY = 500.0 # pixels/s^2 RESTITUTION = 0.8 # bounciness coefficient DAMPING = 0.99 # linear damping per frame ANGULAR_DAMPING = 0.99 # angular damping per frame # Friction coefficient for collisions FRICTION_COEFF = 0.5 # Modes MODE_EDIT = "edit" MODE_SIMULATE = "simulate" mode = MODE_EDIT # Global lists and undo/redo stacks shapes = [] undo_stack = [] redo_stack = [] # State for drawing a new shape drawing_new_shape = False current_drawing_points = [] # absolute coordinates # State for dragging a shape dragging_shape = None drag_offset = (0,0) drag_start_offset = None # Collision mesh grid square size (for visual oriented boxes) square_size = 10 # Debug flag and counters debug_mode = False collision_checks = 0 # number of SAT axis tests impulse_resolutions_count = 0 # count of impulse resolutions performed def rotate_point(x, y, angle): """Rotate a point (x,y) by angle (in radians).""" cos_a = math.cos(angle) sin_a = math.sin(angle) return (x*cos_a - y*sin_a, x*sin_a + y*cos_a) def compute_moment_of_inertia(local_poly, mass): """Approximate moment of inertia (using maximum distance from the local center).""" cx = sum(p[0] for p in local_poly)/len(local_poly) cy = sum(p[1] for p in local_poly)/len(local_poly) max_dist_sq = max((p[0]-cx)**2 + (p[1]-cy)**2 for p in local_poly) return mass * max_dist_sq / 2 def point_in_polygon(x, y, poly): """Ray–casting algorithm to test if (x,y) is inside poly.""" num = len(poly) j = num - 1 inside = False for i in range(num): xi, yi = poly[i] xj, yj = poly[j] if ((yi > y) != (yj > y)) and (x < (xj-xi)*(y-yi)/(yj-yi+1e-9)+xi): inside = not inside j = i return inside def greedy_mesh(grid, rows, cols, square_size): """Combine adjacent True cells into larger rectangles (in local coordinates).""" used = [[False for _ in range(cols)] for _ in range(rows)] rects = [] for row in range(rows): for col in range(cols): if grid[row][col] and not used[row][col]: width = 0 while col+width < cols and grid[row][col+width] and not used[row][col+width]: width += 1 height = 0 valid = True while valid and row+height < rows: for i in range(width): if not grid[row+height][col+i] or used[row+height][col+i]: valid = False break if valid: height += 1 for r in range(row, row+height): for c in range(col, col+width): used[r][c] = True rects.append(pygame.Rect(col*square_size, row*square_size, width*square_size, height*square_size)) return rects def compute_local_collision_boxes(local_poly, square_size): """Compute grid cells inside local_poly and merge them using greedy meshing.""" xs = [p[0] for p in local_poly] ys = [p[1] for p in local_poly] max_x = int(max(xs)) max_y = int(max(ys)) cols = (max_x // square_size) + 1 rows = (max_y // square_size) + 1 grid = [[False for _ in range(cols)] for _ in range(rows)] for row in range(rows): for col in range(cols): cx = col*square_size + square_size/2 cy = row*square_size + square_size/2 if point_in_polygon(cx, cy, local_poly): grid[row][col] = True rects = greedy_mesh(grid, rows, cols, square_size) return rects def sat_collision_polygon(polyA, polyB): """ Uses the Separating Axis Theorem (SAT) to check collision between two convex polygons. Returns (colliding, mtv_normal, penetration_depth). Increments the global collision_checks count for each axis test. """ global collision_checks min_overlap = float('inf') mtv_axis = None for polygon in (polyA, polyB): for i in range(len(polygon)): p1 = polygon[i] p2 = polygon[(i+1)%len(polygon)] edge = (p2[0]-p1[0], p2[1]-p1[1]) axis = (-edge[1], edge[0]) length = math.hypot(axis[0], axis[1]) if length == 0: continue axis = (axis[0]/length, axis[1]/length) collision_checks += 1 minA, maxA = float('inf'), float('-inf') for p in polyA: proj = p[0]*axis[0] + p[1]*axis[1] minA = min(minA, proj) maxA = max(maxA, proj) minB, maxB = float('inf'), float('-inf') for p in polyB: proj = p[0]*axis[0] + p[1]*axis[1] minB = min(minB, proj) maxB = max(maxB, proj) overlap = min(maxA, maxB) - max(minA, minB) if overlap < 0: return (False, None, 0) if overlap < min_overlap: min_overlap = overlap mtv_axis = axis centerA = (sum(p[0] for p in polyA)/len(polyA), sum(p[1] for p in polyA)/len(polyA)) centerB = (sum(p[0] for p in polyB)/len(polyB), sum(p[1] for p in polyB)/len(polyB)) d = (centerB[0]-centerA[0], centerB[1]-centerA[1]) if d[0]*mtv_axis[0] + d[1]*mtv_axis[1] < 0: mtv_axis = (-mtv_axis[0], -mtv_axis[1]) return (True, mtv_axis, min_overlap) class Shape: def __init__(self, local_poly, offset): """ local_poly: list of points (x,y) in local coordinates (with minimum at (0,0)). offset: (x,y) position in the window. """ self.local_poly = local_poly self.offset = list(offset) self.collision_rects = compute_local_collision_boxes(self.local_poly, square_size) self.velocity = [0.0, 0.0] self.angular_velocity = 0.0 # starts at zero; rotation will be derived from collisions self.angle = 0.0 self.color = (random.randint(50,255), random.randint(50,255), random.randint(50,255)) area = abs(0.5 * sum(local_poly[i][0]*local_poly[(i+1)%len(local_poly)][1] - local_poly[(i+1)%len(local_poly)][0]*local_poly[i][1] for i in range(len(local_poly)))) self.mass = area if area > 0 else 1.0 self.inertia = compute_moment_of_inertia(local_poly, self.mass) self.local_center = (sum(p[0] for p in local_poly)/len(local_poly), sum(p[1] for p in local_poly)/len(local_poly)) self.colliding_boxes = set() def get_absolute_polygon(self): """Return the rotated and translated polygon.""" abs_poly = [] for p in self.local_poly: rel = (p[0]-self.local_center[0], p[1]-self.local_center[1]) rot = rotate_point(rel[0], rel[1], self.angle) abs_poly.append((rot[0] + self.offset[0] + self.local_center[0], rot[1] + self.offset[1] + self.local_center[1])) return abs_poly def get_absolute_collision_polys(self): """Return oriented collision boxes (for visualization).""" polys = [] for rect in self.collision_rects: corners = [(rect.x, rect.y), (rect.x+rect.width, rect.y), (rect.x+rect.width, rect.y+rect.height), (rect.x, rect.y+rect.height)] transformed = [] for pt in corners: rel = (pt[0]-self.local_center[0], pt[1]-self.local_center[1]) rot = rotate_point(rel[0], rel[1], self.angle) transformed.append((rot[0] + self.offset[0] + self.local_center[0], rot[1] + self.offset[1] + self.local_center[1])) polys.append(transformed) return polys def get_bounding_rect(self): poly = self.get_absolute_polygon() xs = [p[0] for p in poly] ys = [p[1] for p in poly] return pygame.Rect(min(xs), min(ys), max(xs)-min(xs), max(ys)-min(ys)) def get_center(self): """Return the global center (local center translated).""" return (self.offset[0] + self.local_center[0], self.offset[1] + self.local_center[1]) def update(self, dt): # Apply gravity. self.velocity[1] += GRAVITY * dt # Update position. self.offset[0] += self.velocity[0] * dt self.offset[1] += self.velocity[1] * dt # Update rotation. self.angle += self.angular_velocity * dt # Bounce off window edges. br = self.get_bounding_rect() if br.left < 0: self.offset[0] -= br.left self.velocity[0] = -RESTITUTION * self.velocity[0] if br.right > screen.get_width(): self.offset[0] -= (br.right - screen.get_width()) self.velocity[0] = -RESTITUTION * self.velocity[0] if br.top < 0: self.offset[1] -= br.top self.velocity[1] = -RESTITUTION * self.velocity[1] if br.bottom > screen.get_height(): self.offset[1] -= (br.bottom - screen.get_height()) self.velocity[1] = -RESTITUTION * self.velocity[1] self.velocity[0] *= DAMPING self.velocity[1] *= DAMPING self.angular_velocity *= ANGULAR_DAMPING def draw(self, surface): pygame.draw.polygon(surface, self.color, self.get_absolute_polygon(), 2) polys = self.get_absolute_collision_polys() for idx, poly in enumerate(polys): col = (255,0,0) if idx in self.colliding_boxes else (0,0,255) pygame.draw.polygon(surface, col, poly, 1) def resolve_collision_realistic(shapeA, shapeB, normal, penetration): """ Applies positional correction and impulse–based collision resolution (with friction) so that rotation is derived from collision impulses. """ global impulse_resolutions_count percent = 0.2 slop = 0.01 inv_massA = 1/shapeA.mass inv_massB = 1/shapeB.mass correction = (max(penetration - slop, 0) / (inv_massA + inv_massB)) * percent shapeA.offset[0] -= correction * inv_massA * normal[0] shapeA.offset[1] -= correction * inv_massA * normal[1] shapeB.offset[0] += correction * inv_massB * normal[0] shapeB.offset[1] += correction * inv_massB * normal[1] centerA = shapeA.get_center() centerB = shapeB.get_center() contact_point = ((centerA[0]+centerB[0])/2, (centerA[1]+centerB[1])/2) rA = (contact_point[0]-centerA[0], contact_point[1]-centerA[1]) rB = (contact_point[0]-centerB[0], contact_point[1]-centerB[1]) velA_contact = (shapeA.velocity[0] + -shapeA.angular_velocity * rA[1], shapeA.velocity[1] + shapeA.angular_velocity * rA[0]) velB_contact = (shapeB.velocity[0] + -shapeB.angular_velocity * rB[1], shapeB.velocity[1] + shapeB.angular_velocity * rB[0]) rv = (velB_contact[0]-velA_contact[0], velB_contact[1]-velA_contact[1]) vel_along_normal = rv[0]*normal[0] + rv[1]*normal[1] if vel_along_normal > 0: return e = RESTITUTION rA_cross_n = rA[0]*normal[1] - rA[1]*normal[0] rB_cross_n = rB[0]*normal[1] - rB[1]*normal[0] inv_inertiaA = 1/shapeA.inertia if shapeA.inertia != 0 else 0 inv_inertiaB = 1/shapeB.inertia if shapeB.inertia != 0 else 0 denom = inv_massA + inv_massB + (rA_cross_n**2)*inv_inertiaA + (rB_cross_n**2)*inv_inertiaB if denom == 0: return j = -(1+e)*vel_along_normal/denom impulse = (j*normal[0], j*normal[1]) shapeA.velocity[0] -= impulse[0]*inv_massA shapeA.velocity[1] -= impulse[1]*inv_massA shapeB.velocity[0] += impulse[0]*inv_massB shapeB.velocity[1] += impulse[1]*inv_massB shapeA.angular_velocity -= rA_cross_n * j * inv_inertiaA shapeB.angular_velocity += rB_cross_n * j * inv_inertiaB tangent = (rv[0]-vel_along_normal*normal[0], rv[1]-vel_along_normal*normal[1]) t_length = math.hypot(tangent[0], tangent[1]) if t_length != 0: tangent = (tangent[0]/t_length, tangent[1]/t_length) jt = - (rv[0]*tangent[0] + rv[1]*tangent[1]) / denom jt = max(-j*FRICTION_COEFF, min(jt, j*FRICTION_COEFF)) friction_impulse = (jt*tangent[0], jt*tangent[1]) shapeA.velocity[0] -= friction_impulse[0]*inv_massA shapeA.velocity[1] -= friction_impulse[1]*inv_massA shapeB.velocity[0] += friction_impulse[0]*inv_massB shapeB.velocity[1] += friction_impulse[1]*inv_massB shapeA.angular_velocity -= (rA[0]*tangent[1] - rA[1]*tangent[0]) * jt * inv_inertiaA shapeB.angular_velocity += (rB[0]*tangent[1] - rB[1]*tangent[0]) * jt * inv_inertiaB global impulse_resolutions_count impulse_resolutions_count += 1 class Button: def __init__(self, rect, text): self.rect = pygame.Rect(rect) self.text = text def draw(self, surface): pygame.draw.rect(surface, (100,100,100), self.rect) txt = font.render(self.text, True, (255,255,255)) txt_rect = txt.get_rect(center=self.rect.center) surface.blit(txt, txt_rect) def is_clicked(self, pos): return self.rect.collidepoint(pos) new_shape_btn = Button((10,10,100,30), "New Shape") play_btn = Button((120,10,100,30), "Play") undo_btn = Button((230,10,100,30), "Undo") redo_btn = Button((340,10,100,30), "Redo") edit_btn = Button((450,10,100,30), "Edit") running = True while running: dt = clock.tick(60)/1000.0 for event in pygame.event.get(): if event.type == pygame.QUIT: running = False if event.type == pygame.KEYDOWN: if event.key == pygame.K_d: debug_mode = not debug_mode if mode == MODE_EDIT: if event.type == pygame.MOUSEBUTTONDOWN: pos = pygame.mouse.get_pos() if new_shape_btn.is_clicked(pos): drawing_new_shape = True current_drawing_points = [] dragging_shape = None elif play_btn.is_clicked(pos): mode = MODE_SIMULATE # Launch shapes with random linear velocities but zero angular velocity. for shape in shapes: shape.velocity = [random.choice([-200, -150, 150, 200]), random.choice([-200, -150, 150, 200])] shape.angular_velocity = 0.0 elif undo_btn.is_clicked(pos): if undo_stack: action = undo_stack.pop() if action[0] == "add": shape = action[1] if shape in shapes: shapes.remove(shape) redo_stack.append(("add", shape)) elif action[0] == "move": shape, old_offset, new_offset = action[1], action[2], action[3] shape.offset = list(old_offset) redo_stack.append(("move", shape, new_offset, old_offset)) elif redo_btn.is_clicked(pos): if redo_stack: action = redo_stack.pop() if action[0] == "add": shape = action[1] shapes.append(shape) undo_stack.append(("add", shape)) elif action[0] == "move": shape, old_offset, new_offset = action[1], action[2], action[3] shape.offset = list(new_offset) undo_stack.append(("move", shape, old_offset, new_offset)) elif edit_btn.is_clicked(pos): pass else: if drawing_new_shape: current_drawing_points.append(pos) else: for shape in reversed(shapes): if point_in_polygon(pos[0], pos[1], shape.get_absolute_polygon()): dragging_shape = shape drag_offset = (pos[0]-shape.offset[0], pos[1]-shape.offset[1]) drag_start_offset = shape.offset.copy() break elif event.type == pygame.MOUSEBUTTONUP: if dragging_shape: if drag_start_offset != dragging_shape.offset: undo_stack.append(("move", dragging_shape, drag_start_offset, dragging_shape.offset.copy())) redo_stack.clear() dragging_shape = None elif event.type == pygame.KEYDOWN: if event.key == pygame.K_RETURN and drawing_new_shape and len(current_drawing_points) >= 3: xs = [p[0] for p in current_drawing_points] ys = [p[1] for p in current_drawing_points] min_x, min_y = min(xs), min(ys) local_poly = [(p[0]-min_x, p[1]-min_y) for p in current_drawing_points] new_shape = Shape(local_poly, (min_x, min_y)) shapes.append(new_shape) undo_stack.append(("add", new_shape)) redo_stack.clear() drawing_new_shape = False elif event.key == pygame.K_z: if undo_stack: action = undo_stack.pop() if action[0] == "add": shape = action[1] if shape in shapes: shapes.remove(shape) redo_stack.append(("add", shape)) elif action[0] == "move": shape, old_offset, new_offset = action[1], action[2], action[3] shape.offset = list(old_offset) redo_stack.append(("move", shape, new_offset, old_offset)) elif event.key == pygame.K_y: if redo_stack: action = redo_stack.pop() if action[0] == "add": shape = action[1] shapes.append(shape) undo_stack.append(("add", shape)) elif action[0] == "move": shape, old_offset, new_offset = action[1], action[2], action[3] shape.offset = list(new_offset) undo_stack.append(("move", shape, old_offset, new_offset)) elif mode == MODE_SIMULATE: if event.type == pygame.MOUSEBUTTONDOWN: pos = pygame.mouse.get_pos() if edit_btn.is_clicked(pos): mode = MODE_EDIT for shape in shapes: shape.velocity = [0.0, 0.0] shape.angular_velocity = 0.0 if event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: running = False if mode == MODE_EDIT and dragging_shape: mpos = pygame.mouse.get_pos() dragging_shape.offset[0] = mpos[0]-drag_offset[0] dragging_shape.offset[1] = mpos[1]-drag_offset[1] if mode == MODE_SIMULATE: for shape in shapes: shape.update(dt) shape.colliding_boxes = set() collision_checks = 0 impulse_resolutions_count = 0 for i in range(len(shapes)): for j in range(i+1, len(shapes)): shapeA = shapes[i] shapeB = shapes[j] polyA = shapeA.get_absolute_polygon() polyB = shapeB.get_absolute_polygon() colliding, normal, penetration = sat_collision_polygon(polyA, polyB) if colliding: resolve_collision_realistic(shapeA, shapeB, normal, penetration) screen.fill((30,30,30)) new_shape_btn.draw(screen) play_btn.draw(screen) undo_btn.draw(screen) redo_btn.draw(screen) edit_btn.draw(screen) if drawing_new_shape: if len(current_drawing_points) > 1: pygame.draw.lines(screen, (255,255,255), False, current_drawing_points, 2) for p in current_drawing_points: pygame.draw.circle(screen, (255,0,0), p, 4) for shape in shapes: shape.draw(screen) if mode == MODE_SIMULATE: sim_text = font.render("Simulation Mode - Click Edit to return", True, (255,255,255)) screen.blit(sim_text, (10,50)) else: edit_text = font.render("Edit Mode - Draw/Drag shapes. Enter to finish shape.", True, (255,255,255)) screen.blit(edit_text, (10,50)) if debug_mode: debug_lines = [] debug_lines.append(f"Mode: {mode}") debug_lines.append(f"Shapes: {len(shapes)}") total_boxes = sum(len(s.get_absolute_collision_polys()) for s in shapes) debug_lines.append(f"Oriented Collision Boxes: {total_boxes}") debug_lines.append(f"SAT Axis Tests: {collision_checks}") debug_lines.append(f"Impulse Resolutions: {impulse_resolutions_count}") debug_lines.append(f"FPS: {int(clock.get_fps())}") for i, shape in enumerate(shapes): debug_lines.append(f"Shape {i}: angle={math.degrees(shape.angle):.1f}°, ang_vel={shape.angular_velocity:.2f}") for idx, line in enumerate(debug_lines): txt = font.render(line, True, (255,255,0)) screen.blit(txt, (10, 90+idx*18)) pygame.display.flip() pygame.quit() sys.exit()