Last active
October 18, 2025 18:34
-
-
Save nitori/2451676e44e131df127b4266f88bb8c6 to your computer and use it in GitHub Desktop.
Simple visual demontration of circle and capsule collision. You can drag the circle around.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import math | |
| from dataclasses import dataclass | |
| from collections import deque | |
| import pygame | |
| from pygame import Vector2 | |
| @dataclass | |
| class Circle: | |
| center: Vector2 | |
| radius: float | |
| def __post_init__(self): | |
| self.surf = pygame.Surface(self.rect.size, pygame.SRCALPHA) | |
| pygame.draw.circle(self.surf, 'white', (self.radius, self.radius), self.radius, 0) | |
| @property | |
| def rect(self): | |
| return pygame.Rect( | |
| self.center.x - self.radius, | |
| self.center.y - self.radius, | |
| self.radius * 2, | |
| self.radius * 2 | |
| ) | |
| @dataclass | |
| class Capsule: | |
| center: Vector2 | |
| half_length: float | |
| radius: float | |
| angle: float | |
| def __post_init__(self): | |
| if self.half_length < 0: | |
| raise ValueError('Invalid value for half_length. Must be greater than 0') | |
| self._surf = None | |
| self._last_values = () | |
| @property | |
| def start(self) -> Vector2: | |
| v = Vector2(math.cos(self.angle), math.sin(self.angle)).normalize() * self.half_length | |
| c = self.center | |
| return c - v | |
| @property | |
| def end(self) -> Vector2: | |
| v = Vector2(math.cos(self.angle), math.sin(self.angle)).normalize() * self.half_length | |
| c = self.center | |
| return c + v | |
| @property | |
| def surf(self): | |
| if self._surf is not None: | |
| if self._last_values == (self.half_length, self.radius, self.angle): | |
| print('cached version') | |
| return self._surf | |
| r = self.rect | |
| # get local start/end points | |
| tl = Vector2(r.x, r.y) | |
| lstart = self.start - tl | |
| lend = self.end - tl | |
| surf = pygame.Surface(r.size, pygame.SRCALPHA) | |
| pygame.draw.circle(surf, 'white', lstart, self.radius, 0) | |
| pygame.draw.circle(surf, 'white', lend, self.radius, 0) | |
| # get normals of vector from start to end | |
| v_se = lend - lstart | |
| if self.half_length > 0: | |
| n_se_1 = Vector2(-v_se.y, v_se.x).normalize() * self.radius | |
| n_se_2 = Vector2(v_se.y, -v_se.x).normalize() * self.radius | |
| else: | |
| n_se_1 = Vector2() | |
| n_se_2 = Vector2() | |
| pygame.draw.polygon(surf, 'white', [ | |
| lstart + n_se_1, | |
| lend + n_se_1, | |
| lend + n_se_2, | |
| lstart + n_se_2, | |
| ], 0) | |
| self._surf = surf | |
| self._last_values = self.half_length, self.radius, self.angle | |
| return surf | |
| @property | |
| def rect(self) -> pygame.Rect: | |
| x1 = min(self.start.x, self.end.x) - self.radius | |
| y1 = min(self.start.y, self.end.y) - self.radius | |
| x2 = max(self.start.x, self.end.x) + self.radius | |
| y2 = max(self.start.y, self.end.y) + self.radius | |
| return pygame.Rect(x1, y1, (x2 - x1), (y2 - y1)) | |
| def main(): | |
| pygame.init() | |
| screen = pygame.display.set_mode((800, 600)) | |
| layer1 = pygame.Surface(screen.size, pygame.SRCALPHA) | |
| clock = pygame.Clock() | |
| circle = Circle( | |
| center=Vector2(500, 200), | |
| radius=40 | |
| ) | |
| capsule = Capsule( | |
| center=Vector2(400, 300), | |
| half_length=100, | |
| radius=50, | |
| angle=0.0 | |
| ) | |
| rotation_speed = .5 | |
| drag_circle_offset: Vector2 | None = None | |
| font = pygame.Font(size=24) | |
| fps_q = deque(maxlen=10) | |
| while True: | |
| delta = clock.tick(60) / 1000 | |
| fps_q.append(1 / delta) | |
| screen.fill('black') | |
| layer1.fill('black') | |
| for event in pygame.event.get(): | |
| if event.type == pygame.QUIT: | |
| pygame.quit() | |
| return | |
| if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: | |
| if math.dist(circle.center, event.pos) < circle.radius: | |
| drag_circle_offset = Vector2(event.pos[0], event.pos[1]) - circle.center | |
| if event.type == pygame.MOUSEBUTTONUP and event.button == 1: | |
| drag_circle_offset = None | |
| if not screen.get_rect().colliderect(circle.rect): | |
| # out of bound. reset | |
| circle.center.x = 500 | |
| circle.center.y = 200 | |
| # update capsule | |
| ticks = pygame.time.get_ticks() / 1000 | |
| capsule.angle = (ticks * rotation_speed) % math.tau | |
| # update circle | |
| if drag_circle_offset is not None: | |
| mx, my = pygame.mouse.get_pos() | |
| circle.center.x = mx - drag_circle_offset.x | |
| circle.center.y = my - drag_circle_offset.y | |
| # project the circle into the capsules "local space" | |
| centers_vector = circle.center - capsule.center | |
| u = Vector2(math.cos(capsule.angle), math.sin(capsule.angle)) | |
| # we only need the x coord in that local spaces, as the y values is always 0. | |
| local_x = centers_vector.dot(u) | |
| # the point on the capsules central line in local space | |
| # by clamping the projected circles x value to -half_length, +half_length. | |
| proj_line_point = Vector2( | |
| # 'x | |
| pygame.math.clamp(local_x, -capsule.half_length, capsule.half_length), | |
| # 'y | |
| 0 | |
| ) | |
| # calculate the "world space" of proj_line_point | |
| # Note: for y we would add this as well: | |
| # proj_line_point.y * Vector2(-u.y, u.x) | |
| # but since it's 0 anyway, we can just skip it. | |
| world_line_point = capsule.center + proj_line_point.x * u | |
| # check collision | |
| # distance (squared) between circle and closest point on capsule central line | |
| length_sq = (circle.center - world_line_point).length_squared() | |
| # sum of radius (squared) | |
| radius_sum_sq = (capsule.radius + circle.radius) ** 2 | |
| # colliding if lenght is shorter than the radius | |
| colliding: bool = length_sq < radius_sum_sq | |
| # draw yellow area the mark the "perpendicular area" of the capsule. | |
| # the same calculations as in the capsule surf rendering, just extended | |
| v_se = capsule.end - capsule.start | |
| n_se_1 = Vector2(-v_se.y, v_se.x).normalize() * screen.width * 2 | |
| n_se_2 = Vector2(v_se.y, -v_se.x).normalize() * screen.width * 2 | |
| perp_area = [capsule.start + n_se_1, capsule.end + n_se_1, capsule.end + n_se_2, capsule.start + n_se_2] | |
| # draw on layer1, as we can't draw polygons with transparent color | |
| pygame.draw.polygon(layer1, 'yellow', perp_area, 0) | |
| layer1.set_alpha(32) | |
| screen.blit(layer1) | |
| pygame.draw.polygon(screen, 'yellow', perp_area, 2) | |
| # draw shapes | |
| screen.blit(circle.surf, circle.rect) | |
| screen.blit(capsule.surf, capsule.rect) | |
| pygame.draw.line(screen, 'blue', world_line_point, circle.center, 2) | |
| pygame.draw.line(screen, 'black', capsule.start, capsule.end, 2) | |
| pygame.draw.circle(screen, 'red', world_line_point, capsule.radius, 0 if colliding else 2) | |
| # draw text | |
| radius_sum_text = f'Radius sum: {capsule.radius} (capsule) + {circle.radius} (circle) = {radius_sum_sq ** .5}' | |
| distance_text = f'Blue line length = {length_sq ** .5:.1f}' | |
| fps_test = f'FPS: {round(sum(fps_q) / len(fps_q))}' | |
| collide_text = None | |
| if colliding: | |
| collide_text = ' COLLISION' | |
| sum_surf = font.render(radius_sum_text, True, 'white') | |
| distance_surf = font.render(distance_text, True, 'white') | |
| fps_surf = font.render(fps_test, True, 'white') | |
| screen.blit(sum_surf, (10, 10)) | |
| screen.blit(distance_surf, (10, 10 * 2 + sum_surf.height)) | |
| screen.blit(fps_surf, (10, 10 * 3 + sum_surf.height + distance_surf.height)) | |
| if collide_text: | |
| collide_surf = font.render(collide_text, True, (255, 64, 64)) | |
| screen.blit(collide_surf, ( | |
| 10 + distance_surf.width + 10, | |
| 10 + sum_surf.height + 10 | |
| )) | |
| pygame.display.flip() | |
| if __name__ == '__main__': | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment