""" pyPhysicsSandbox is a simple wrapper around Pymunk that makes it easy to write code to explore physics simulations. It's intended for use in introductory programming classrooms. Caution! The simulation does not behave well if you start out with shapes overlapping each other, especially if overlapping shapes are connected with joints. To have overlapping shapes connected by joints, set the group on each shape to the same number to disable collision detection between those shape. Shapes far enough outside the simulation window (generally, above or below by the height of the window, or to either side by the width of the window) are automatically removed from the simulation and their active property set to False. The distance can be modified, but be wary of making it too large...this keeps shapes that are not visible in the simulation and can slow the simulation down if the number of shapes grows too large. """ import pygame import pymunk import math from pygame import Color from pygame import constants __docformat__ = "reStructuredText" __all__ = ['window', 'add_observer', 'gravity', 'resistance', 'mouse_clicked', 'static_ball', 'ball', 'static_box', 'box', 'static_rounded_box', 'rounded_box', 'static_polygon', 'polygon', 'static_triangle', 'triangle', 'static_text', 'text', 'static_text_with_font', 'text_with_font', 'static_line', 'line', 'pivot', 'gear', 'motor', 'pin', 'rotary_spring', 'run', 'draw', 'Color', 'cosmetic_text', 'cosmetic_text_with_font', 'num_shapes', 'constants', 'deactivate', 'reactivate', 'mouse_point', 'add_collision', 'slip_motor', 'set_margins', 'cosmetic_box', 'cosmetic_rounded_box', 'cosmetic_ball', 'cosmetic_line', 'cosmetic_polygon', 'cosmetic_triangle', 'spring', 'color' ] pygame.init() space = pymunk.Space() space.gravity = (0.0, 500.0) space.damping = 0.95 win_title = "Untitled" win_width = 500 win_height = 500 x_margin = win_width y_margin = win_height observers = [] clicked = False default_color = Color('black') shapes = {} def window(title, width, height): """Sets the caption, width, and height of the window that will appear when run () is executed. :param title: the caption of the window :type title: string :param width: the width of the window in pixels :type width: int :param height: the height of the window in pixels :type height: int """ global win_title global win_width global win_height global x_margin global y_margin win_title = title win_width = width win_height = height x_margin = win_width y_margin = win_height def add_observer(hook): """Adds an observer function to the simulation. Every observer function is called once per time step of the simulation (roughly 50 times a second). The function should be defined like this: def function_name(keys): # do something each time step To pass a function in use the name of the function without the parenthesis after it. The observer function must take a single parameter which is a list of keys pressed this step. To see if a particular key has been pressed, use something like this: if constants.K_UP in keys: # do something based on the up arrow being pressed :param hook: the observer function :type hook: function """ global observers observers.append(hook) def set_margins(x, y): """Sets the distance outside the simulation that shapes can be and remain active. This defaults to the window width and height. You can change it to either remove shapes more quickly when they're out of view, or to allow creating shapes farther outside the visible window. :param x: horizontal margin :param y: vertical margin """ global x_margin global y_margin x_margin = x y_margin = y def gravity(x, y): """Sets the direction and amount of gravity used by the simulation. Positive x is to the right, positive y is downward. This value can be changed during the run of the simulation. :param x: The horizontal gravity :type x: int :param y: The vertical gravity :type y: int """ space.gravity = (x, y) def color(c): """Sets the default color to use for shapes created after this call. The function may be called at any point to change the color for new shapes. To see available color names go to https://sites.google.com/site/meticulosslacker/pygame-thecolors and hover the mouse pointer over a color of interest. :param c: the color name as a string :type c: str """ global default_color default_color = Color(c) def resistance(v): """Sets the amount of velocity that all objects lose each second. This can be used to simulate air resistance. Resistance value defaults to 1.0. Values less than 1.0 cause objects to lose velocity over time, values greater than 1.0 cause objects to gain velocity over time. For example a value of .9 means the body will lose 10% of its velocity each second (.9 = 90% velocity retained each second). This value can be changed during the run of the simulation. :param v: The resistance value :type v: float """ space.damping = v def mouse_clicked(): """Returns True if the mouse has been clicked this time step. Usable only in an observer function. :rtype: bool """ return clicked def mouse_point(): """Returns the current location of the mouse pointer. If the mouse is out of the simulation window, this will return the last location of the mouse that was in the simulation window. :rtype: (x, y) """ return pygame.mouse.get_pos() def static_ball(p, radius): """Creates a ball that remains fixed in place. :param p: The center point of the ball :type p: (int, int) :param radius: The radius of the ball :type radius: int :rtype: shape """ return _ball(p, radius, pymunk.inf, True) def ball(p, radius, mass=-1): """Creates a ball that reacts to gravity. :param p: The center point of the ball :type p: (int, int) :param radius: The radius of the ball :type radius: int :param mass: The mass of the shape (defaults to 1) :type mass: int :rtype: shape """ return _ball(p, radius, mass, False) def cosmetic_ball(p, radius): """Creates a ball that does not interact with the simulation in any way. :param p: The center point of the ball :type p: (int, int) :param radius: The radius of the ball :type radius: int :rtype: shape """ return _ball(p, radius, 0, False, True) def _ball(p, radius, mass, static=False, cosmetic=False): from .ball_shape import Ball if mass == -1: mass = math.pi*radius*radius result = Ball(space, p[0], p[1], radius, mass, static, cosmetic) result.color = default_color shapes[result.collision_type] = result return result def static_box(p, width, height): """Creates a box that remains fixed in place. :param p: The upper left corner of the box :type p: (int, int) :param width: The width of the box :type width: int :param height: The height of the box :type height: int :rtype: shape """ return _box(p, width, height, pymunk.inf, True) def box(p, width, height, mass=-1): """Creates a box that reacts to gravity. :param p: The upper left corner of the box :type p: (int, int) :param width: The width of the box :type width: int :param height: The height of the box :type height: int :param mass: The mass of the shape (defaults to 1) :type mass: int :rtype: shape """ return _box(p, width, height, mass, False) def cosmetic_box(p, width, height): """Creates a box that does not react with the simulation in any way. :param p: The upper left corner of the box :type p: (int, int) :param width: The width of the box :type width: int :param height: The height of the box :type height: int :rtype: shape """ return _box(p, width, height, 0, False, 0, True) def _box(p, width, height, mass, static, radius=0, cosmetic=False): from .box_shape import Box if mass == -1: mass = width * height # Polygons expect x,y to be the center point x = p[0] + width / 2 y = p[1] + height / 2 result = Box(space, x, y, width, height, radius, mass, static, cosmetic) result.color = default_color shapes[result.collision_type] = result return result def static_rounded_box(p, width, height, radius): """Creates a box with rounded corners that remains fixed in place. :param p: The upper left corner of the box :type p: (int, int) :param width: The width of the box :type width: int :param height: The height of the box :type height: int :param radius: The radius of the rounded corners :type radius: int :rtype: shape """ return _box(p, width, height, pymunk.inf, True, radius) def rounded_box(p, width, height, radius, mass=-1): """Creates a box with rounded corners that reacts to gravity. :param p: The upper left corner of the box :type p: (int, int) :param width: The width of the box :type width: int :param height: The height of the box :type height: int :param radius: The radius of the rounded corners :type radius: int :param mass: The mass of the shape (defaults to 1) :type mass: int :rtype: shape """ return _box(p, width, height, mass, False, radius) def cosmetic_rounded_box(p, width, height, radius): """Creates a box with rounded corners that does not interact with the simulation in any way. :param p: The upper left corner of the box :type p: (int, int) :param width: The width of the box :type width: int :param height: The height of the box :type height: int :param radius: The radius of the rounded corners :type radius: int :rtype: shape """ return _box(p, width, height, 0, False, radius, True) def static_polygon(vertices): """Creates a polygon that remains fixed in place. :param vertices: A tuple of points on the polygon :type vertices: ((int, int), (int, int), ...) :rtype: shape """ return _polygon(vertices, pymunk.inf, True) def polygon(vertices, mass=-1): """Creates a polygon that reacts to gravity. :param vertices: A tuple of points on the polygon :type vertices: ((int, int), (int, int), ...) :param mass: The mass of the shape (defaults to 1) :type mass: int :rtype: shape """ return _polygon(vertices, mass, False) def cosmetic_polygon(vertices): """Creates a polygon that does not interact with the simulation in any way. :param vertices: A tuple of points on the polygon :type vertices: ((int, int), (int, int), ...) :rtype: shape """ return _polygon(vertices, 0, False, True) def _polygon(vertices, mass, static, cosmetic=False): from .poly_shape import Poly from .util import poly_centroid from .util import poly_area x, y = poly_centroid(vertices) if mass == -1: mass = poly_area(vertices) vertices = [(v[0] - x, v[1] - y) for v in vertices] result = Poly(space, x, y, vertices, 0, mass, static, cosmetic) result.color = default_color shapes[result.collision_type] = result return result def static_triangle(p1, p2, p3): """Creates a triangle that remains fixed in place. :param p1: The first point of the triangle :type p1: (int, int) :param p2: The second point of the triangle :type p2: (int, int) :param p3: The third point of the triangle :type p3: (int, int) :rtype: shape """ return _triangle(p1, p2, p3, pymunk.inf, True) def triangle(p1, p2, p3, mass=-1): """Creates a triangle that reacts to gravity. :param p1: The first point of the triangle :type p1: (int, int) :param p2: The second point of the triangle :type p2: (int, int) :param p3: The third point of the triangle :type p3: (int, int) :param mass: The mass of the shape (defaults to 1) :type mass: int :rtype: shape """ return _triangle(p1, p2, p3, mass, False) def cosmetic_triangle(p1, p2, p3): """Creates a triangle that does not interact with the simulation in any way. :param p1: The first point of the triangle :type p1: (int, int) :param p2: The second point of the triangle :type p2: (int, int) :param p3: The third point of the triangle :type p3: (int, int) :rtype: shape """ return _triangle(p1, p2, p3, 0, False, True) def _triangle(p1, p2, p3, mass, static, cosmetic=False): from .poly_shape import Poly from .util import poly_area x1, y1 = p1 x2, y2 = p2 x3, y3 = p3 x = (x1 + x2 + x3) / 3 y = (y1 + y2 + y3) / 3 vertices = ((x1 - x, y1 - y), (x2 - x, y2 - y), (x3 - x, y3 - y)) if mass == -1: mass = poly_area(vertices) result = Poly(space, x, y, vertices, 0, mass, static, cosmetic) result.color = default_color shapes[result.collision_type] = result return result def static_text(p, caption): """Creates a text rectangle that remains fixed in place, using Arial 12 point font. :param p: The upper left corner of the text rectangle :type p: (int, int) :param caption: The text to display :type caption: string :rtype: shape """ return _text(p, caption, pymunk.inf, True) def text(p, caption, mass=-1): """Creates a text rectangle that reacts to gravity, using Arial 12 point font. :param p: The upper left corner of the text rectangle :type p: (int, int) :param caption: The text to display :type caption: string :param mass: The mass of the shape (defaults to 1) :type mass: int :rtype: shape """ return _text(p, caption, mass, False) def cosmetic_text(p, caption): """Creates text that displays on the screen but does not interact with other objects in any way. :param p: The upper left corner of the text :type p: (int, int) :param caption: The text to display :type caption: string :rtype: shape """ return _text(p, caption, 0, False, True) def _text(p, caption, mass, static, cosmetic=False): from .text_shape import Text if mass == -1: mass = 10 * len(caption) result = Text(space, p[0], p[1], caption, "Arial", 12, mass, static, cosmetic) result.color = default_color shapes[result.collision_type] = result return result def static_text_with_font(p, caption, font, size): """Creates a text rectangle that remains fixed in place. :param p: The upper left corner of the text rectangle :type p: (int, int) :param caption: The text to display :type caption: string :param font: The font family to use :type font: string :param size: The point size of the font :type size: int :rtype: shape """ return _text_with_font(p, caption, font, size, pymunk.inf, True) def text_with_font(p, caption, font, size, mass=-1): """Creates a text rectangle that reacts to gravity. :param p: The upper left corner of the text rectangle :type p: (int, int) :param caption: The text to display :type caption: string :param font: The font family to use :type font: string :param size: The point size of the font :type size: int :param mass: The mass of the shape (defaults to 1) :type mass: int :rtype: shape """ return _text_with_font(p, caption, font, size, mass, False) def cosmetic_text_with_font(p, caption, font, size): """Creates text that displays on the screen but does not interact with other objects in any way. :param p: The upper left corner of the text :type p: (int, int) :param caption: The text to display :type caption: string :param font: The font family to use :type font: string :param size: The point size of the font :type size: int :rtype: shape """ return _text_with_font(p, caption, font, size, 0, False, True) def _text_with_font(p, caption, font, size, mass, static, cosmetic=False): from .text_shape import Text if mass == -1: mass = 10 * len(caption) result = Text(space, p[0], p[1], caption, font, size, mass, static, cosmetic) result.color = default_color shapes[result.collision_type] = result return result def static_line(p1, p2, thickness): """Creates a line segment that remains fixed in place. :param p1: The starting point of the line segement :type p1: (int, int) :param p2: The ending point of the line segement :type p2: (int, int) :param thickness: The thickness of the line segement :type thickness: int :rtype: shape """ return _line(p1, p2, thickness, pymunk.inf, True) def line(p1, p2, thickness, mass=-1): """Creates a line segment that will react to gravity. :param p1: The starting point of the line segement :type p1: (int, int) :param p2: The ending point of the line segement :type p2: (int, int) :param thickness: The thickness of the line segement :type thickness: int :param mass: The mass of the shape (defaults to 1) :type mass: int :rtype: shape """ return _line(p1, p2, thickness, mass, False) def cosmetic_line(p1, p2, thickness): """Creates a line segment that does not interact with the simulation in any way. :param p1: The starting point of the line segement :type p1: (int, int) :param p2: The ending point of the line segement :type p2: (int, int) :param thickness: The thickness of the line segement :type thickness: int :rtype: shape """ return _line(p1, p2, thickness, 0, False, True) def _line(p1, p2, thickness, mass, static, cosmetic=False): from .line_segment import Line if mass == -1: mass = math.sqrt(math.pow(p1[0]-p2[0], 2)+math.pow(p1[1]-p2[1], 2))*thickness result = Line(space, p1, p2, thickness, mass, static, cosmetic) result.color = default_color shapes[result.collision_type] = result return result def pivot(p): """Creates a pivot joint around which shapes can freely rotate. Shapes must be connected to the pivot using the connect method on the returned shape. The pivot joint remains fixed in place. :param p: The point at which to place the pivot :type p: (int, int) :rtype: shape """ from .pivot_joint import Pivot result = Pivot(space, p[0], p[1]) result.color = default_color shapes[result.collision_type] = result return result def gear(shape1, shape2): """Connects two objects such that their rotations become the same. Can be used in conjunction with a motor on one shape to ensure the second shape rotates at the same speed as the first. :param shape1: The first shape to connect :type shape1: shape :param shape2: The second shape to connect :type shape2: shape :rtype: shape """ from .gear_joint import Gear result = Gear(space, shape1, shape2) result.color = default_color shapes[result.collision_type] = result return result def motor(shape1, speed=5): """Creates a constant rotation of the given shape around its center point. The direction of rotation is controlled by the sign of the speed. Positive speed is clockwise, negative speed is counter-clockwise. :param shape1: The shape to connect to the motor :type shape1: shape :param speed: The speed at which to rotate the shape :type speed: int :rtype: shape """ from .motor_joint import Motor result = Motor(space, shape1, speed) result.color = default_color shapes[result.collision_type] = result return result def pin(p1, shape1, p2, shape2): """Creates a connection between the shapes at the given positions. Those points on the shapes will remain that same distance apart, regardless of movement or rotation. :param p1: The point on the first shape :type p1: (int, int) :param shape1: The first shape to connect via the pin :type shape1: shape :param p2: The point on the second shape :type p2: (int, int) :param shape2: The second shape to connect via the pin :type shape2: shape :rtype: shape """ from .pin_joint import Pin result = Pin(space, p1, shape1, p2, shape2) result.color = default_color shapes[result.collision_type] = result return result def spring(p1, shape1, p2, shape2, length, stiffness, damping): """Creates a connection between the shapes at the given positions. Those points on the shapes will remain that same distance apart, regardless of movement or rotation. :param p1: The point on the first shape :type p1: (int, int) :param shape1: The first shape to connect via the pin :type shape1: shape :param p2: The point on the second shape :type p2: (int, int) :param shape2: The second shape to connect via the pin :type shape2: shape :param length: The length the spring wants to be :type length: float :param stiffness: The spring constant (Young’s modulus) :type stiffness: float :param damping: How soft to make the damping of the spring :type damping: float :rtype: shape """ from .spring_joint import Spring p1 = (p1[0]-shape1.position[0], p1[1]-shape1.position[1]) p2 = (p2[0]-shape2.position[0], p2[1]-shape2.position[1]) result = Spring(space, p1, shape1, p2, shape2, length, stiffness, damping) result.color = default_color shapes[result.collision_type] = result return result def slip_motor(shape1, shape2, rest_angle, stiffness, damping, slip_angle, speed): """Creates a combination spring and motor. The motor will rotate shape1 around shape2 at the given speed. When shape1 reaches the slip angle it will spring back to the rest_angle. Then the motor will start to rotate the object again. :param shape1: The first shape to connect via the spring :type shape1: shape :param shape2: The second shape to connect via the spring :type shape2: shape :param rest_angle: The desired angle between the two objects :type rest_angle: float :param stiffness: the spring constant (Young's modulus) :type stiffness: float :param damping: the softness of the spring damping :type damping: float :param slip_angle: The angle at which to release the object :type slip_angle: float :param speed: The speed at which to rotate the shape :type speed: int :rtype: shape """ from .slip_motor import SlipMotor result = SlipMotor(space, shape1, shape2, rest_angle, stiffness, damping, slip_angle, speed) result.color = default_color shapes[result.collision_type] = result return result def rotary_spring(shape1, shape2, angle, stiffness, damping): """Creates a spring that constrains the rotations of the given shapes. The angle between the two shapes prefers to be at the given angle, but may be varied by forces on the objects. The spring will bring the objects back to the desired angle. The initial positioning of the shapes is considered to be at an angle of 0. :param shape1: The first shape to connect via the spring :type shape1: shape :param shape2: The second shape to connect via the spring :type shape2: shape :param angle: The desired angle between the two objects :type angle: float :param stiffness: the spring constant (Young's modulus) :type stiffness: float :param damping: the softness of the spring damping :type damping: float :rtype: shape """ from .rotary_spring import RotarySpring result = RotarySpring(space, shape1, shape2, angle, stiffness, damping) result.color = default_color shapes[result.collision_type] = result return result def num_shapes(): """Returns the number of active shapes in the simulation. :rtype int """ return len(shapes) def deactivate(shape): """Removes the given shape from the simulation. The shape will no longer display or interact with other objects :param shape: the shape to deactivate """ if not shape.active: return shape.deactivate() del shapes[shape.collision_type] def reactivate(shape): """The given shape will be reactivated. Its position and velocity will remain the same as it was when it was deactivated. :param shape: the shape to activate """ if shape.active: return shape.reactivate() shapes[shape.collision_type] = shape def add_collision(shape1, shape2, handler): """Tells the sandbox to call a function when the two given shapes collide. The handler function is called once per collision, at the very start of the collision. The handler function is passed three parameters. The first two are the colliding shapes, the third is the point of the collision, e.g.: handler(shape1, shape2, p) :param shape1: the first shape in the collision :param shape2: the other shape in the collision :param handler: the function to call :return: """ temp = space.add_collision_handler(shape1.collision_type, shape2.collision_type) temp.data['handler'] = handler temp.begin = handle_collision def handle_collision(arbiter, space, data): shape1 = shapes[arbiter.shapes[0].collision_type] shape2 = shapes[arbiter.shapes[1].collision_type] p = arbiter.contact_point_set.points[0].point_a return data['handler'](shape1, shape2, p) def _calc_margins(): global x_margin global y_margin for shape in shapes: x, y = shapes[shape].position x = abs(x) y = abs(y) if x_margin < x: x_margin = x if y_margin < y: y_margin = y def run(do_physics=True): """Call this after you have created all your shapes to actually run the simulation. This function returns only when the user has closed the simulation window. Pass False to this method to do the drawing but not activate physics. Useful for getting the scene right before running the simulation. :param do_physics: Should physics be activated or not :type do_physics: bool """ global clicked _calc_margins() screen = pygame.display.set_mode((win_width, win_height)) pygame.display.set_caption(win_title) clock = pygame.time.Clock() running = True while running: keys = [] clicked = False for event in pygame.event.get(): if event.type == pygame.QUIT: running = False if event.type == pygame.KEYDOWN: keys.append(event.key) if event.type == pygame.MOUSEBUTTONDOWN: clicked = True for observer in observers: observer(keys) screen.fill((255, 255, 255)) # Should automatically remove any shapes that are # far enough below the bottom edge of the window # that they won't be involved in anything visible shapes_to_remove = [] for collision_type, shape in shapes.items(): if shape.position.x > win_width + x_margin: shapes_to_remove.append(shape) if shape.position.x < -x_margin: shapes_to_remove.append(shape) if shape.position.y > win_height + y_margin: shapes_to_remove.append(shape) if shape.position.y < -y_margin: shapes_to_remove.append(shape) for shape in shapes_to_remove: deactivate(shape) # Also adjust positions for any shapes that are supposed # to wrap and have gone off an edge of the screen. for collision_type, shape in shapes.items(): if shape.wrap_x: if shape.position.x < 0: shape.position = (win_width - 1, shape.position.y) if shape.position.x >= win_width: shape.position = (0, shape.position.y) if shape.wrap_y: if shape.position.y < 0: shape.position = (shape.position.x, win_height - 1) if shape.position.y >= win_height: shape.position = (shape.position.x, 0) # Now draw the shapes that are left for collision_type, shape in shapes.items(): shape.draw(screen) if do_physics: space.step(1 / 50.0) pygame.display.flip() clock.tick(50) pygame.quit() def draw(): """Call this after you have created all your shapes to actually draw them. This function only returns after you close the window. This is an alias for run(False). """ run(False)