def test_sprite_sides_set(sprite_class, params: SpriteParams, results: SideSetterResults): sprite = sprite_class(width=params.width, height=params.height) sprite.left = params.position.x expected = results.left assert sprite.left == expected.left assert sprite.right == expected.right assert sprite.top == expected.top assert sprite.bottom == expected.bottom sprite.position = Vector(0, 0) sprite.right = params.position.x expected = results.right assert sprite.left == expected.left assert sprite.right == expected.right assert sprite.top == expected.top assert sprite.bottom == expected.bottom sprite.position = Vector(0, 0) sprite.top = params.position.y expected = results.top assert sprite.left == expected.left assert sprite.right == expected.right assert sprite.top == expected.top assert sprite.bottom == expected.bottom sprite.position = Vector(0, 0) sprite.bottom = params.position.y expected = results.bottom assert sprite.left == expected.left assert sprite.right == expected.right assert sprite.top == expected.top assert sprite.bottom == expected.bottom
def test_truncate_equivalent_to_scale(x: Vector, max_length: float): """Vector.scale_to and truncate are equivalent when max_length <= x.length""" assume(max_length <= x.length) note(f"x.length = {x.length}") if max_length > 0: note(f"x.length = {x.length / max_length} * max_length") scale: Union[Vector, Type[Exception]] truncate: Union[Vector, Type[Exception]] try: truncate = x.truncate(max_length) except Exception as e: truncate = type(e) try: scale = x.scale_to(max_length) except Exception as e: event(f"Exception {type(e).__name__} thrown") scale = type(e) if isinstance(scale, Vector) and x.length == max_length: # Permit some edge-case where truncation and scaling aren't equivalent assert isinstance(truncate, Vector) assert scale.isclose(truncate, abs_tol=0, rel_tol=1e-12) else: assert scale == truncate
def center(self) -> Vector: """ Get the midpoint vector """ if self.side in (TOP, BOTTOM): return Vector(self.parent.center.x, float(self)) else: return Vector(float(self), self.parent.center.y)
def test_trig_invariance(angle: float, n: int): """Test that cos(θ), sin(θ) ≃ cos(θ + n*360°), sin(θ + n*360°)""" r_cos, r_sin = Vector._trig(angle) n_cos, n_sin = Vector._trig(angle + 360 * n) note(f"δcos: {r_cos - n_cos}") assert isclose(r_cos, n_cos, rel_to=[n / 1e9]) note(f"δsin: {r_sin - n_sin}") assert isclose(r_sin, n_sin, rel_to=[n / 1e9])
def test_class_attrs(): class TestSprite(BaseSprite): position = Vector(4, 2) sprite = TestSprite() assert sprite.position == Vector(4, 2) sprite = TestSprite(position=(2, 4)) assert sprite.position == Vector(2, 4)
def bottom_right(self, vector: Vector): """ The coordinates of the bottom right corner of the object. Can be set to a :class:`ppb_vector.Vector`. """ vector = Vector(vector) x = vector.x - (self.width / 2) y = vector.y + (self.height / 2) self.position = Vector(x, y)
def test_rectangle_shape_mixin_center(): class TestSprite(RectangleShapeMixin, BaseSprite): pass test_sprite = TestSprite() assert test_sprite.center == test_sprite.position test_sprite.center = Vector(100, 100) assert test_sprite.center == test_sprite.position assert test_sprite.center == Vector(100, 100)
def test_dot_rotational_invariance(x: Vector, y: Vector, angle: float): """Test that rotating vectors doesn't change their dot product.""" t = x.angle(y) cos_t, _ = Vector._trig(t) note(f"θ: {t}") note(f"cos θ: {cos_t}") # Exclude near-orthogonal test inputs assume(abs(cos_t) > 1e-6) assert isclose(x * y, x.rotate(angle) * y.rotate(angle), rel_to=(x, y), rel_exp=2)
def mouse_motion(self, event, scene): screen_position = Vector(*event.pos) camera = scene.main_camera scene_position = camera.translate_to_frame(screen_position) delta = Vector(*event.rel) * (1 / camera.pixel_ratio) buttons = { self.button_map[btn + 1] for btn, value in enumerate(event.buttons) if value } return events.MouseMotion(position=scene_position, screen_position=screen_position, delta=delta, buttons=buttons)
def test_reflect_angle(initial: Vector, normal: Vector): """Test angle-related properties of Vector.reflect: * initial.reflect(normal) * normal == - initial * normal * normal.angle(initial) == 180 - normal.angle(reflected) """ # Exclude initial vectors that are very small or very close to the surface. assume(not angle_isclose(initial.angle(normal) % 180, 90, epsilon=10)) assume(initial.length > 1e-10) reflected = initial.reflect(normal) assert isclose((initial * normal), -(reflected * normal)) assert angle_isclose(normal.angle(initial), 180 - normal.angle(reflected))
def test_rotation_stability(angle, loops): """Rotating loops times by angle is equivalent to rotating by loops*angle.""" initial = Vector(1, 0) fellswoop = initial.rotate(angle * loops) note(f"One Fell Swoop: {fellswoop}") stepwise = initial for _ in range(loops): stepwise = stepwise.rotate(angle) note(f"Step-wise: {stepwise}") assert fellswoop.isclose(stepwise, rel_tol=1e-8) assert math.isclose(fellswoop.length, initial.length, rel_tol=1e-15)
def __init__(self, **kwargs): super().__init__() self.position = Vector(self.position) # Initialize things for k, v in kwargs.items(): # Abbreviations if k == 'pos': k = 'position' # Castings if k == 'position': v = Vector(v) setattr(self, k, v)
class RotatableMixin: """ A rotation mixin. Can be included with sprites. .. warning:: rotation does not affect underlying shape (the corners are still in the same place), it only rotates the sprites image and provides a facing. """ rotation = 0 # This is necessary to make facing do the thing while also being adjustable. #: The baseline vector, representing the "front" of the sprite basis = Vector(0, -1) # Considered making basis private, the only reason to do so is to # discourage people from relying on it as data. @property def facing(self): """ The direction the "front" is facing. Can be set to an arbitrary facing by providing a facing vector. """ return Vector(*self.basis).rotate(self.rotation).normalize() @facing.setter def facing(self, value): self.rotation = self.basis.angle(value) def rotate(self, degrees): """ Rotate the sprite by a given angle (in degrees). """ self.rotation = (self.rotation + degrees) % 360
def facing(self): """ The direction the "front" is facing. Can be set to an arbitrary facing by providing a facing vector. """ return Vector(*self.basis).rotate(self.rotation).normalize()
def test_dot_from_angle(x: Vector, y: Vector): """Test x · y == |x| · |y| · cos(θ)""" t = x.angle(y) cos_t, _ = Vector._trig(t) # Dismiss near-othogonal test inputs assume(abs(cos_t) > 1e-6) min_len, max_len = sorted((x.length, y.length)) geometric = min_len * (max_len * cos_t) note(f"θ: {t}") note(f"cos θ: {cos_t}") note(f"algebraic: {x * y}") note(f"geometric: {geometric}") assert isclose(x * y, geometric, rel_to=(x, y), rel_exp=2)
def bottom_left(self) -> Vector: """ The coordinates of the bottom left corner of the object. Can be set to a :class:`ppb_vector.Vector`. """ return Vector(self.left, self.bottom)
def top_right(self) -> Vector: """ The coordinates of the top right corner of the object. Can be set to a :class:`ppb_vector.Vector`. """ return Vector(self.right, self.top)
class BaseSprite(EventMixin): """ The base Sprite class. All sprites should inherit from this (directly or indirectly). The things that define a BaseSprite: * The __event__ protocol (see ppb.eventlib.EventMixin) * A position vector * A layer BaseSprite provides an __init__ method that sets attributes based on kwargs to make rapid prototyping easier. """ #: (:py:class:`ppb.Vector`): Location of the sprite position: Vector = Vector(0, 0) #: The layer a sprite exists on. layer: int = 0 def __init__(self, **kwargs): super().__init__() self.position = Vector(self.position) # Initialize things for k, v in kwargs.items(): # Abbreviations if k == 'pos': k = 'position' # Castings if k == 'position': v = Vector(v) setattr(self, k, v)
def __init__(self, **props): """ :class:`BaseSprite` does not accept any positional arguments, and uses keyword arguments to set arbitrary state to the :class:`BaseSprite` instance. This allows rapid prototyping. Example: :: sprite = BaseSprite(speed=6) print(sprite.speed) This sample will print the numeral 6. You may add any arbitrary data values in this fashion. Alternatively, it is considered best practice to subclass :class:`BaseSprite` and set the default values of any required attributes as class attributes. Example: :: class Rocket(ppb.sprites.BaseSprite): velocity = Vector(0, 1) def on_update(self, update_event, signal): self.position += self.velocity * update_event.time_delta """ super().__init__(**props) # Type coercion self.position = Vector(self.position)
def top_middle(self) -> Vector: """ The coordinates of the midpoint of the top of the object. Can be set to a :class:`ppb_vector.Vector`. """ return Vector(self.position.x, self.top)
def right_middle(self) -> Vector: """ The coordinates of the midpoint of the right side of the object. Can be set to a :class:`ppb_vector.Vector`. """ return Vector(self.right, self.position.y)
def translate_to_frame(self, point: Vector) -> Vector: """ Converts a vector from pixel-based window to in-game coordinate space """ # 1. Scale from pixels to game unites scaled = point / self.pixel_ratio # 2. Reposition relative to frame edges return Vector(self.frame_left + scaled.x, self.frame_top - scaled.y)
def translate_to_viewport(self, point: Vector) -> Vector: """ Converts a vector from in-game to pixel-based window coordinate space """ # 1. Reposition based on frame edges # 2. Scale from game units to pixels return Vector(point.x - self.frame_left, self.frame_top - point.y) * self.pixel_ratio
def test_sides_bottom_right_set(x, y, vector_type): sprite = Sprite() sprite.bottom.right = vector_type((x, y)) bottom_right = sprite.bottom.right right_bottom = sprite.right.bottom assert bottom_right == right_bottom assert bottom_right == Vector(x, y) assert sprite.position == bottom_right + Vector(-0.5, 0.5) # duplicating to prove top.left and left.top are the same. sprite = Sprite() sprite.right.bottom = vector_type((x, y)) bottom_right = sprite.bottom.right right_bottom = sprite.right.bottom assert right_bottom == bottom_right assert right_bottom == Vector(x, y) assert sprite.position == right_bottom + Vector(-0.5, 0.5)
def test_sides_bottom_right_plus_equals(x, y, vector_type): sprite = Sprite() sprite.bottom.right += vector_type((x, y)) bottom_right = sprite.bottom.right right_bottom = sprite.right.bottom assert bottom_right == right_bottom assert bottom_right == Vector(x + 0.5, y - 0.5) assert sprite.position == bottom_right + Vector(-0.5, 0.5) # duplicating to prove bottom.left and left.bottom are the same. sprite = Sprite() sprite.bottom.left += vector_type((x, y)) bottom_right = sprite.bottom.right right_bottom = sprite.right.bottom assert right_bottom == bottom_right assert right_bottom == Vector(x + 0.5, y - 0.5) assert sprite.position == right_bottom + Vector(-0.5, 0.5)
def test_rotatable_subclass(): class TestRotatableMixin(RotatableMixin): _rotation = 180 basis = Vector(0, 1) rotatable = TestRotatableMixin() assert rotatable.rotation == 180 assert rotatable.facing == Vector(0, -1)
def test_sides_top_left_set(x, y, vector_type): sprite = Sprite() sprite.top.left = vector_type((x, y)) top_left = sprite.top.left left_top = sprite.left.top assert top_left == left_top assert top_left == Vector(x, y) assert sprite.position == top_left + Vector(0.5, -0.5) # duplicating to prove top.left and left.top are the same. sprite = Sprite() sprite.left.top = vector_type((x, y)) top_left = sprite.top.left left_top = sprite.left.top assert left_top == top_left assert left_top == Vector(x, y) assert sprite.position == left_top + Vector(0.5, -0.5)
def mouse_motion(self, event, scene): motion = event.motion screen_position = Vector(motion.x, motion.y) camera = scene.main_camera scene_position = camera.translate_point_to_game_space(screen_position) delta = Vector(motion.xrel, motion.yrel) * (1 / camera.pixel_ratio) buttons = { value for btn, value in self.button_mask_map.items() if motion.state & btn } return events.MouseMotion( position=scene_position, delta=delta, buttons=buttons, # timestamp=motion.timestamp )
def test_sides_top_right_plus_equals(x, y, vector_type): sprite = Sprite() sprite.top.right += vector_type((x, y)) top_right = sprite.top.right right_top = sprite.right.top assert top_right == right_top assert top_right == Vector(x + 0.5, y + 0.5) assert sprite.position == top_right + Vector(-0.5, -0.5) # duplicating to prove top.left and left.top are the same. sprite = Sprite() sprite.top.left += vector_type((x, y)) top_right = sprite.top.right right_top = sprite.right.top assert right_top == top_right assert right_top == Vector(x + 0.5, y + 0.5) assert sprite.position == right_top + Vector(-0.5, -0.5)
def _mk_update_vector_center(self, value): """ Calculate the update vector, based on the given side. That is, handles the calculation for forms like sprite.right = number """ value = Vector(value) # Pretty similar to ._mk_update_vector_side() self_dimension, self_offset = self._lookup_side(self.side) attr_dimension = 'y' if self_dimension == 'x' else 'x' fields = { self_dimension: value[self_dimension] - self_offset, attr_dimension: value[attr_dimension] } return Vector(fields)