Source code for gale.physics.world

"""
This file contains the implementation of the class World: a physics
simulation, in pixel units, wrapping a Box2D world without ever
exposing Box2D itself — creates every Body/Joint, steps the
simulation, and dispatches collision callbacks.

Author: Alejandro Mujica (aledrums@gmail.com)
"""

from typing import Any, Callable, List, Optional, Tuple

import Box2D
import pygame

from .body import Body
from .body_type import BodyType
from .joint import Joint, RevoluteJoint, WheelJoint
from .shapes import BoxShape, CircleShape, PolygonShape

OnCollision = Callable[[Body, Body], None]

# Box2D's own recommended values, unrelated to pixels_per_meter.
VELOCITY_ITERATIONS: int = 8
POSITION_ITERATIONS: int = 3


class _ContactListener(Box2D.b2ContactListener):
    def __init__(self, world: "World") -> None:
        super().__init__()
        self._world = world

    def BeginContact(self, contact) -> None:
        self._world._dispatch_contact(contact, self._world._begin_callbacks)

    def EndContact(self, contact) -> None:
        self._world._dispatch_contact(contact, self._world._end_callbacks)


[docs] class World: """ A physics simulation, in pixel units. Wraps a Box2D world (never exposed) and every Body/Joint created in it. Follows the same update/fixed_update split Unity uses to decouple the game loop's real, variable dt from the fixed timestep Box2D's solver needs for stable results: call update(dt) once a frame, same as everything else in gale; it accumulates dt and calls fixed_update() (a real Box2D step) as many times as needed to consume it. fixed_update() is itself a public method, useful for tests (or the rare game) that want to drive the simulation one fixed step at a time directly. Usage example: world = World(gravity=(0, 900)) ground = world.create_static_body(200, 280, BoxShape(400, 20)) ball = world.create_dynamic_body(200, 50, CircleShape(radius=10)) # In your state: def update(self, dt: float) -> None: self.world.update(dt) """ def __init__( self, gravity: Tuple[float, float] = (0, 900), pixels_per_meter: float = 30.0, fixed_timestep: float = 1 / 60, ) -> None: """ :param gravity: Acceleration applied to every dynamic body, in pixels per second squared. Positive y points down the screen (gale's usual convention), so the default value, (0, 900), is a normal-feeling "downward" gravity. The default value is (0, 900). :param pixels_per_meter: Conversion factor between this World's public pixel units and the meters Box2D's solver expects internally (Box2D is tuned for body sizes of roughly 0.1 to 10 meters; too small or too large is numerically unstable). The default value is 30.0, meaning a 30-pixel-wide object is treated as 1 meter wide. :param fixed_timestep: The fixed timestep fixed_update() advances the simulation by, in seconds. The default value is 1 / 60. """ self.pixels_per_meter: float = pixels_per_meter self.fixed_timestep: float = fixed_timestep self._accumulator: float = 0.0 self._b2_world = Box2D.b2World( gravity=(gravity[0] / pixels_per_meter, gravity[1] / pixels_per_meter) ) self._listener = _ContactListener(self) self._b2_world.contactListener = self._listener self._begin_callbacks: List[OnCollision] = [] self._end_callbacks: List[OnCollision] = []
[docs] def create_static_body( self, x: float, y: float, shape: Optional[Any] = None ) -> Body: """ :param x: Initial x position, in pixels. :param y: Initial y position, in pixels. :param shape: A CircleShape/BoxShape/PolygonShape to attach immediately. The default value is None, so fixtures can be added later via Body.add_circle/add_box/add_polygon. :returns: The new Body. """ return self._create_body(BodyType.STATIC, x, y, shape)
[docs] def create_dynamic_body( self, x: float, y: float, shape: Optional[Any] = None ) -> Body: """ :param x: Initial x position, in pixels. :param y: Initial y position, in pixels. :param shape: A CircleShape/BoxShape/PolygonShape to attach immediately. The default value is None, so fixtures can be added later via Body.add_circle/add_box/add_polygon. :returns: The new Body. """ return self._create_body(BodyType.DYNAMIC, x, y, shape)
[docs] def create_kinematic_body( self, x: float, y: float, shape: Optional[Any] = None ) -> Body: """ :param x: Initial x position, in pixels. :param y: Initial y position, in pixels. :param shape: A CircleShape/BoxShape/PolygonShape to attach immediately. The default value is None, so fixtures can be added later via Body.add_circle/add_box/add_polygon. :returns: The new Body. """ return self._create_body(BodyType.KINEMATIC, x, y, shape)
def _create_body( self, body_type: int, x: float, y: float, shape: Optional[Any] ) -> Body: position = (x / self.pixels_per_meter, y / self.pixels_per_meter) if body_type == BodyType.STATIC: b2_body = self._b2_world.CreateStaticBody(position=position) elif body_type == BodyType.KINEMATIC: b2_body = self._b2_world.CreateKinematicBody(position=position) else: b2_body = self._b2_world.CreateDynamicBody(position=position) body = Body(b2_body, body_type, self.pixels_per_meter) if isinstance(shape, CircleShape): body.add_circle(shape) elif isinstance(shape, BoxShape): body.add_box(shape) elif isinstance(shape, PolygonShape): body.add_polygon(shape) return body
[docs] def destroy_body(self, body: Body) -> None: """ :param body: The Body to remove from this World. """ body.destroy()
[docs] def create_revolute_joint( self, body_a: Body, body_b: Body, anchor: Tuple[float, float], **options: Any ) -> RevoluteJoint: """ :param body_a: The first body. :param body_b: The second body. :param anchor: The pivot point, in pixels, in world coordinates. :param options: Forwarded to Box2D's revolute joint definition (e.g. enableLimit, lowerAngle, upperAngle). :returns: The new RevoluteJoint. """ ppm = self.pixels_per_meter b2_joint = self._b2_world.CreateRevoluteJoint( bodyA=body_a._b2_body, bodyB=body_b._b2_body, anchor=(anchor[0] / ppm, anchor[1] / ppm), **options, ) return RevoluteJoint(b2_joint)
[docs] def create_wheel_joint( self, body_a: Body, body_b: Body, anchor: Tuple[float, float], axis: Tuple[float, float] = (0, 1), **options: Any, ) -> WheelJoint: """ :param body_a: The chassis (or other body the wheel is attached to). :param body_b: The wheel. :param anchor: The suspension's attachment point, in pixels, in world coordinates. :param axis: The suspension's movement axis, in body_a's local frame. The default value is (0, 1) (straight up/down). :param options: Forwarded to Box2D's wheel joint definition (e.g. frequencyHz, dampingRatio, maxMotorTorque, motorSpeed, enableMotor). :returns: The new WheelJoint. """ ppm = self.pixels_per_meter b2_joint = self._b2_world.CreateWheelJoint( bodyA=body_a._b2_body, bodyB=body_b._b2_body, anchor=(anchor[0] / ppm, anchor[1] / ppm), axis=axis, **options, ) return WheelJoint(b2_joint)
[docs] def destroy_joint(self, joint: Joint) -> None: """ :param joint: The Joint to remove from this World. """ joint.destroy(self._b2_world)
[docs] def on_collision_begin(self, callback: OnCollision) -> None: """ :param callback: Called with (body_a, body_b) when two fixtures start touching. At least one of the two must not be a sensor for physical collision response; either or both may be sensors for overlap-only detection. """ self._begin_callbacks.append(callback)
[docs] def on_collision_end(self, callback: OnCollision) -> None: """ :param callback: Called with (body_a, body_b) when two fixtures that were touching stop touching. """ self._end_callbacks.append(callback)
def _dispatch_contact(self, contact, callbacks: List[OnCollision]) -> None: body_a = contact.fixtureA.body.userData body_b = contact.fixtureB.body.userData if not isinstance(body_a, Body) or not isinstance(body_b, Body): return for callback in callbacks: callback(body_a, body_b)
[docs] def update(self, dt: float) -> None: """ Call once a frame with the real, variable elapsed time. Steps the simulation forward by calling fixed_update() as many times as the accumulated time covers. :param dt: Time elapsed, in seconds, since the last call. """ self._accumulator += dt while self._accumulator >= self.fixed_timestep: self.fixed_update() self._accumulator -= self.fixed_timestep
[docs] def fixed_update(self) -> None: """ Advance the simulation by exactly one fixed_timestep. """ self._b2_world.Step( self.fixed_timestep, VELOCITY_ITERATIONS, POSITION_ITERATIONS ) self._b2_world.ClearForces()
[docs] def render_debug(self, surface: pygame.Surface, color=(0, 255, 0)) -> None: """ Draw every fixture's outline directly onto surface, with no assets — a debugging aid for building a level. :param surface: The surface to draw on. :param color: The outline color. The default value is (0, 255, 0). """ ppm = self.pixels_per_meter for b2_body in self._b2_world.bodies: for fixture in b2_body.fixtures: shape = fixture.shape if isinstance(shape, Box2D.b2CircleShape): center = b2_body.transform * shape.pos pygame.draw.circle( surface, color, (center * ppm), shape.radius * ppm, 1 ) elif isinstance(shape, Box2D.b2PolygonShape): points = [ (b2_body.transform * vertex) * ppm for vertex in shape.vertices ] pygame.draw.polygon(surface, color, points, 1)