diff --git a/demo/demo.py b/demo/demo.py index f1a65de..6d171a2 100644 --- a/demo/demo.py +++ b/demo/demo.py @@ -85,7 +85,8 @@ def update(self, camera, wall_collision_group, layer_size, timedelta): self.sprite.rect.topleft = oldie else: # we did NOT wrap around the screen - collided_with = collide.move_as_close_as_possible(self.sprite, new_coord, wall_collision_group) + closest_position, collided_with = collide.move_as_close_as_possible(self.sprite, new_coord, wall_collision_group) + self.sprite.rect.topleft = closest_position camera.scroll_to(self.sprite.rect) diff --git a/sappho/collide.py b/sappho/collide.py index 78723a0..93dfa82 100644 --- a/sappho/collide.py +++ b/sappho/collide.py @@ -1,9 +1,7 @@ """Handle generic pygame collision. -The ColliderSprite lets you have a positional -sprite, with a mask and rect, which can be -efficiently and easily tested against sprite -groups with similar data. +Maybe this shouldn't ever move a sprite, but always return +a coordinate and any sprites it collides with. """ @@ -56,10 +54,30 @@ def collides_rect_mask(sprite, sprite_group): def move_as_close_as_possible(sprite, destination, sprite_group): - """Move along a line to destination coordinate, stopping before - potential collision. - - Move as close as possible to `destination` without collision. + """Return how close sprite can go to destination without collision, + along with the first sprite blocking its progress (if any). + + "Position" herein will always refer to a position + for sprite.rect.topleft. + + Say you have a SPACESHIP (S), "blank space" (-), the + destination (D), and a collidable BLOCK (B). If the + SPACESHIP moves at a velocity of x+5 y+5, this function + would would take (6, 6) as the `destination`, and it would + return ((3, 3), ). This directly + mitigates "bad" collision where you could simply jump from + 1,1 to 6,6 despite there being a collision in the path (BLOCK). + + 123456 + 1 S----- + 2 ------ + 3 ------ + 4 ---B-- + 5 ------ + 6 -----D + + In the example above, if there were no BLOCK (B) the return value + would be ((6,6), None). Warning: This isn't fast! It lacks any decent heuristics, such as @@ -67,7 +85,11 @@ def move_as_close_as_possible(sprite, destination, sprite_group): against line-based collision heuristics in the future. Arguments: - sprite (pygame.Sprite): the sprite to move as close... + sprite (pygame.Sprite): This sprite is used to incrementally + move toward the destination, continuously checking + if colliding with any in sprite_group. The sprite's position + will not be affected (you will have to update the + sprite.rect.topleft respectively, yourself). destination (tuple[x, y]): The goal coordinate to move to, or at least as close to as possible before colliding. sprite_group (pygame.sprite.Group): Pygame sprite group, whose @@ -75,12 +97,26 @@ def move_as_close_as_possible(sprite, destination, sprite_group): toward the destination. Returns: - pygame.Sprite: The first sprite detected which prevented - moving further in the path. - None: Moved to destination without collision. + tuple: The first element is the "topleft" coordinate + representing the closest sprite may move toward the + destination before a collision occurs. The first element + coordinate will be one of the following: + * At the original "topleft" position of sprite, i.e., + sprite.rect.topleft + * The destination provided: if there were no collisions + moving between original position and destination. + * In between original and destination positions: when + there was a collision, this will be the last value + that didn't collide along that path toward destination. + The second element is the first sprite which + prevents progressing further toward destination. The second + element could be None if sprite can move to destination + without any collisions from sprite_group. """ + original_position = sprite.rect.topleft + # Figure out the x and y increments! # # I use "increment" herein to mean "step which approaches, @@ -123,10 +159,10 @@ def move_as_close_as_possible(sprite, destination, sprite_group): colliding_with = collides_rect_mask(sprite, sprite_group) if colliding_with: - sprite.rect.topleft = last_safe_topleft - return colliding_with + sprite.rect.topleft = original_position + return (last_safe_topleft, colliding_with) - return None + return (destination, None) def sprites_in_orthogonal_path(sprite, new_coord, sprite_group): @@ -134,8 +170,8 @@ def sprites_in_orthogonal_path(sprite, new_coord, sprite_group): and thus collide with if it moved to new_coord. Warning: - This does not work diagonally! This is shamefully bad, but - works perfectly for orthogonal movement. + This does not work diagonally! This is only good for + quickly checking collisions along an orthogonal path. Arguments: new_coord (tuple[int, int]): topleft coordinate value this diff --git a/tests/test_collide.py b/tests/test_collide.py index ead99db..f2e6f08 100644 --- a/tests/test_collide.py +++ b/tests/test_collide.py @@ -2,46 +2,54 @@ import pygame -from sappho import collide, animate +from sappho import animate +from sappho import collide -class TestColliderSprite(object): +# this sprite is 10x10 +testpath = os.path.realpath(__file__) +path = os.path.abspath(os.path.join(testpath, + "..", + "resources", + "animatedsprite.gif")) - # NOTE, TODO: this is a pretty bad test. Ideally, it - # would do something more specific in addition to retesting - # after the collision_sprite is updated, which should then - # correspond to that position of the GIF/AnimatedSprite. - def test_basic_attributes(self): - testpath = os.path.realpath(__file__) - path = os.path.abspath(os.path.join(testpath, - "..", - "resources", - "animatedsprite.gif")) - animsprite = animate.AnimatedSprite.from_gif(path, - mask_threshold=254) - collision_sprite = collide.ColliderSprite(animsprite) +animsprite_mask_20_20 = animate.AnimatedSprite.from_gif( + path, + mask_threshold=254 +) - assert collision_sprite.rect.size == (10, 10) - assert hasattr(collision_sprite, 'mask') - # TODO: should test after updating sprite +animsprite_mask_20_20.rect.topleft = (20, 20) - def mock_sprite_group(self): - pass +animsprite_mask_40_40 = animate.AnimatedSprite.from_gif( + path, + mask_threshold=254 +) - def test_collides_rect(self): - pass +animsprite_mask_40_40.rect.topleft = (40, 40) - def test_collides_rect_mask(self): - pass +animsprite_group_sans_one = pygame.sprite.Group(animsprite_mask_40_40) -# The below pattern can be used for collides_rect, collides_Rect_mask, -# try_to_move +def test_move_close_as_possible(): + """ -# Create a group of sprites with various unique positions + Move `animsprite_mask_20_20` to (60, 60), which should collide + with both `animsprite_mask_40_40` and `animsprite_35_40`. -# Create a sprite which will be checked for collisions -# against the group of sprites created in the last step. -# We intentionally place this collidersprite somewhere that'll -# collide with at least one colliddersprite from the group of -# the last step + """ + + closest_to_goal, collided_with = collide.move_as_close_as_possible( + animsprite_mask_20_20, + (60, 60), + animsprite_group_sans_one + ) + assert closest_to_goal == (30, 30) + assert collided_with is animsprite_mask_40_40 + + closest_to_goal, collided_with = collide.move_as_close_as_possible( + animsprite_mask_20_20, + (10, 10), + animsprite_group_sans_one + ) + assert closest_to_goal == (10, 10) + assert collided_with is None